Merge pull request #951 from omnivore-app/feature/typeahead-search

feature/typeahead search
This commit is contained in:
Hongbo Wu 2022-07-13 11:53:32 +08:00 committed by GitHub
commit ab687ca832
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 393 additions and 15 deletions

View file

@ -548,3 +548,57 @@ export const deletePagesByParam = async <K extends keyof ParamSet>(
return false
}
}
export const searchAsYouType = async (
userId: string,
query: string,
size = 5
): Promise<Page[]> => {
try {
const { body } = await client.search<SearchResponse<Page>>({
index: INDEX_ALIAS,
body: {
query: {
bool: {
filter: [
{
term: {
userId,
},
},
{
multi_match: {
query,
type: 'bool_prefix',
fields: [
'title',
'title._2gram',
'title._3gram',
'siteName',
'siteName._2gram',
'siteName._3gram',
],
},
},
],
},
},
_source: ['title', 'slug', 'siteName'],
size,
},
})
if (body.hits.total.value === 0) {
return []
}
return body.hits.hits.map((hit: { _source: Page; _id: string }) => ({
...hit._source,
id: hit._id,
}))
} catch (e) {
console.error('failed to search as you type in elastic', e)
return []
}
}

View file

@ -1280,6 +1280,7 @@ export type Query = {
sendInstallInstructions: SendInstallInstructionsResult;
sharedArticle: SharedArticleResult;
subscriptions: SubscriptionsResult;
typeaheadSearch: TypeaheadSearchResult;
user: UserResult;
users: UsersResult;
validateUsername: Scalars['Boolean'];
@ -1351,6 +1352,12 @@ export type QuerySubscriptionsArgs = {
};
export type QueryTypeaheadSearchArgs = {
first?: InputMaybe<Scalars['Int']>;
query: Scalars['String'];
};
export type QueryUserArgs = {
userId?: InputMaybe<Scalars['ID']>;
username?: InputMaybe<Scalars['String']>;
@ -1922,6 +1929,30 @@ export type SubscriptionsSuccess = {
subscriptions: Array<Subscription>;
};
export type TypeaheadSearchError = {
__typename?: 'TypeaheadSearchError';
errorCodes: Array<TypeaheadSearchErrorCode>;
};
export enum TypeaheadSearchErrorCode {
Unauthorized = 'UNAUTHORIZED'
}
export type TypeaheadSearchItem = {
__typename?: 'TypeaheadSearchItem';
id: Scalars['ID'];
siteName?: Maybe<Scalars['String']>;
slug: Scalars['String'];
title: Scalars['String'];
};
export type TypeaheadSearchResult = TypeaheadSearchError | TypeaheadSearchSuccess;
export type TypeaheadSearchSuccess = {
__typename?: 'TypeaheadSearchSuccess';
items: Array<TypeaheadSearchItem>;
};
export type UnsubscribeError = {
__typename?: 'UnsubscribeError';
errorCodes: Array<UnsubscribeErrorCode>;
@ -2664,6 +2695,11 @@ export type ResolversTypes = {
SubscriptionsErrorCode: SubscriptionsErrorCode;
SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess'];
SubscriptionsSuccess: ResolverTypeWrapper<SubscriptionsSuccess>;
TypeaheadSearchError: ResolverTypeWrapper<TypeaheadSearchError>;
TypeaheadSearchErrorCode: TypeaheadSearchErrorCode;
TypeaheadSearchItem: ResolverTypeWrapper<TypeaheadSearchItem>;
TypeaheadSearchResult: ResolversTypes['TypeaheadSearchError'] | ResolversTypes['TypeaheadSearchSuccess'];
TypeaheadSearchSuccess: ResolverTypeWrapper<TypeaheadSearchSuccess>;
UnsubscribeError: ResolverTypeWrapper<UnsubscribeError>;
UnsubscribeErrorCode: UnsubscribeErrorCode;
UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess'];
@ -2961,6 +2997,10 @@ export type ResolversParentTypes = {
SubscriptionsError: SubscriptionsError;
SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess'];
SubscriptionsSuccess: SubscriptionsSuccess;
TypeaheadSearchError: TypeaheadSearchError;
TypeaheadSearchItem: TypeaheadSearchItem;
TypeaheadSearchResult: ResolversParentTypes['TypeaheadSearchError'] | ResolversParentTypes['TypeaheadSearchSuccess'];
TypeaheadSearchSuccess: TypeaheadSearchSuccess;
UnsubscribeError: UnsubscribeError;
UnsubscribeResult: ResolversParentTypes['UnsubscribeError'] | ResolversParentTypes['UnsubscribeSuccess'];
UnsubscribeSuccess: UnsubscribeSuccess;
@ -3782,6 +3822,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
sendInstallInstructions?: Resolver<ResolversTypes['SendInstallInstructionsResult'], ParentType, ContextType>;
sharedArticle?: Resolver<ResolversTypes['SharedArticleResult'], ParentType, ContextType, RequireFields<QuerySharedArticleArgs, 'slug' | 'username'>>;
subscriptions?: Resolver<ResolversTypes['SubscriptionsResult'], ParentType, ContextType, Partial<QuerySubscriptionsArgs>>;
typeaheadSearch?: Resolver<ResolversTypes['TypeaheadSearchResult'], ParentType, ContextType, RequireFields<QueryTypeaheadSearchArgs, 'query'>>;
user?: Resolver<ResolversTypes['UserResult'], ParentType, ContextType, Partial<QueryUserArgs>>;
users?: Resolver<ResolversTypes['UsersResult'], ParentType, ContextType>;
validateUsername?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<QueryValidateUsernameArgs, 'username'>>;
@ -4138,6 +4179,28 @@ export type SubscriptionsSuccessResolvers<ContextType = ResolverContext, ParentT
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type TypeaheadSearchErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['TypeaheadSearchError'] = ResolversParentTypes['TypeaheadSearchError']> = {
errorCodes?: Resolver<Array<ResolversTypes['TypeaheadSearchErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type TypeaheadSearchItemResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['TypeaheadSearchItem'] = ResolversParentTypes['TypeaheadSearchItem']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
siteName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type TypeaheadSearchResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['TypeaheadSearchResult'] = ResolversParentTypes['TypeaheadSearchResult']> = {
__resolveType: TypeResolveFn<'TypeaheadSearchError' | 'TypeaheadSearchSuccess', ParentType, ContextType>;
};
export type TypeaheadSearchSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['TypeaheadSearchSuccess'] = ResolversParentTypes['TypeaheadSearchSuccess']> = {
items?: Resolver<Array<ResolversTypes['TypeaheadSearchItem']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UnsubscribeErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UnsubscribeError'] = ResolversParentTypes['UnsubscribeError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UnsubscribeErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4574,6 +4637,10 @@ export type Resolvers<ContextType = ResolverContext> = {
SubscriptionsError?: SubscriptionsErrorResolvers<ContextType>;
SubscriptionsResult?: SubscriptionsResultResolvers<ContextType>;
SubscriptionsSuccess?: SubscriptionsSuccessResolvers<ContextType>;
TypeaheadSearchError?: TypeaheadSearchErrorResolvers<ContextType>;
TypeaheadSearchItem?: TypeaheadSearchItemResolvers<ContextType>;
TypeaheadSearchResult?: TypeaheadSearchResultResolvers<ContextType>;
TypeaheadSearchSuccess?: TypeaheadSearchSuccessResolvers<ContextType>;
UnsubscribeError?: UnsubscribeErrorResolvers<ContextType>;
UnsubscribeResult?: UnsubscribeResultResolvers<ContextType>;
UnsubscribeSuccess?: UnsubscribeSuccessResolvers<ContextType>;

View file

@ -924,6 +924,7 @@ type Query {
sendInstallInstructions: SendInstallInstructionsResult!
sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult!
subscriptions(sort: SortParams): SubscriptionsResult!
typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult!
user(userId: ID, username: String): UserResult!
users: UsersResult!
validateUsername(username: String!): Boolean!
@ -1443,6 +1444,27 @@ type SubscriptionsSuccess {
subscriptions: [Subscription!]!
}
type TypeaheadSearchError {
errorCodes: [TypeaheadSearchErrorCode!]!
}
enum TypeaheadSearchErrorCode {
UNAUTHORIZED
}
type TypeaheadSearchItem {
id: ID!
siteName: String
slug: String!
title: String!
}
union TypeaheadSearchResult = TypeaheadSearchError | TypeaheadSearchSuccess
type TypeaheadSearchSuccess {
items: [TypeaheadSearchItem!]!
}
type UnsubscribeError {
errorCodes: [UnsubscribeErrorCode!]!
}

View file

@ -22,6 +22,7 @@ import {
QueryArticleArgs,
QueryArticlesArgs,
QuerySearchArgs,
QueryTypeaheadSearchArgs,
ResolverFn,
SaveArticleReadingProgressError,
SaveArticleReadingProgressErrorCode,
@ -35,6 +36,9 @@ import {
SetShareArticleError,
SetShareArticleErrorCode,
SetShareArticleSuccess,
TypeaheadSearchError,
TypeaheadSearchErrorCode,
TypeaheadSearchSuccess,
} from '../../generated/graphql'
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Merge } from '../../util'
@ -82,6 +86,7 @@ import {
deletePage,
getPageById,
getPageByParam,
searchAsYouType,
searchPages,
updatePage,
} from '../../elastic/pages'
@ -889,3 +894,25 @@ export const searchResolver = authorized<
},
}
})
export const typeaheadSearchResolver = authorized<
TypeaheadSearchSuccess,
TypeaheadSearchError,
QueryTypeaheadSearchArgs
>(async (_obj, { query, first }, { claims }) => {
if (!claims?.uid) {
return { errorCodes: [TypeaheadSearchErrorCode.Unauthorized] }
}
analytics.track({
userId: claims.uid,
event: 'typeahead',
properties: {
env: env.server.apiEnv,
query,
first,
},
})
return { items: await searchAsYouType(claims.uid, query, first || undefined) }
})

View file

@ -76,6 +76,7 @@ import {
signupResolver,
subscribeResolver,
subscriptionsResolver,
typeaheadSearchResolver,
unsubscribeResolver,
updateHighlightResolver,
updateLabelResolver,
@ -188,6 +189,7 @@ export const functionResolvers = {
webhooks: webhooksResolver,
webhook: webhookResolver,
apiKeys: apiKeysResolver,
typeaheadSearch: typeaheadSearchResolver,
},
User: {
async sharedArticles(
@ -589,4 +591,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('ApiKeys'),
...resultResolveTypeResolver('RevokeApiKey'),
...resultResolveTypeResolver('DeleteAccount'),
...resultResolveTypeResolver('TypeaheadSearch'),
}

View file

@ -1763,6 +1763,27 @@ const schema = gql`
labelIds: [ID!]!
}
union TypeaheadSearchResult = TypeaheadSearchSuccess | TypeaheadSearchError
type TypeaheadSearchSuccess {
items: [TypeaheadSearchItem!]!
}
type TypeaheadSearchError {
errorCodes: [TypeaheadSearchErrorCode!]!
}
enum TypeaheadSearchErrorCode {
UNAUTHORIZED
}
type TypeaheadSearchItem {
id: ID!
title: String!
slug: String!
siteName: String
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -1876,6 +1897,7 @@ const schema = gql`
webhooks: WebhooksResult!
webhook(id: ID!): WebhookResult!
apiKeys: ApiKeysResult!
typeaheadSearch(query: String!, first: Int): TypeaheadSearchResult!
}
`

View file

@ -17,6 +17,7 @@ import {
deletePagesByParam,
getPageById,
getPageByParam,
searchAsYouType,
searchPages,
updatePage,
} from '../../src/elastic/pages'
@ -339,4 +340,39 @@ describe('elastic api', () => {
expect(deleted).to.be.true
})
})
describe('searchAsYouType', () => {
before(async () => {
// create a testing page
await createPage(
{
content: '',
createdAt: new Date(),
hash: '',
id: '',
pageType: PageType.Article,
readingProgressAnchorIndex: 0,
readingProgressPercent: 0,
savedAt: new Date(),
slug: '',
state: ArticleSavingRequestStatus.Succeeded,
title: 'search as you type',
url: '',
userId,
},
ctx
)
})
after(async () => {
// delete the testing page
await deletePagesByParam({ userId }, ctx)
})
it('searches pages', async () => {
const searchResults = await searchAsYouType(userId, 'search')
expect(searchResults).to.have.lengthOf(1)
expect(searchResults[0].title).to.eq('search as you type')
})
})
})

View file

@ -317,6 +317,25 @@ const saveArticleReadingProgressQuery = (
`
}
const typeaheadSearchQuery = (keyword: string) => {
return `
query {
typeaheadSearch(query: "${keyword}") {
... on TypeaheadSearchSuccess {
items {
id
slug
title
}
}
... on TypeaheadSearchError {
errorCodes
}
}
}
`
}
describe('Article API', () => {
const username = 'fakeUser'
let authToken: string
@ -1069,4 +1088,54 @@ describe('Article API', () => {
})
})
})
describe('TypeaheadSearch API', () => {
const pages: Page[] = []
let query = ''
let keyword = 'typeahead'
before(async () => {
// Create some test pages
for (let i = 0; i < 5; i++) {
const page: Page = {
id: '',
hash: '',
userId: user.id,
pageType: PageType.Article,
title: 'typeahead search page',
content: '',
slug: '',
createdAt: new Date(),
updatedAt: new Date(),
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
url: '',
savedAt: new Date(),
state: ArticleSavingRequestStatus.Succeeded,
}
const pageId = await createPage(page, ctx)
if (!pageId) {
expect.fail('Failed to create page')
}
page.id = pageId
pages.push(page)
}
})
beforeEach(async () => {
query = typeaheadSearchQuery(keyword)
})
it('should return pages with typeahead prefix', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.search.edges.length).to.eql(5)
expect(res.body.data.search.edges[0].node.id).to.eq(pages[4].id)
expect(res.body.data.search.edges[1].node.id).to.eq(pages[3].id)
expect(res.body.data.search.edges[2].node.id).to.eq(pages[2].id)
expect(res.body.data.search.edges[3].node.id).to.eq(pages[1].id)
expect(res.body.data.search.edges[4].node.id).to.eq(pages[0].id)
})
})
})

View file

@ -24,7 +24,7 @@
"type": "keyword"
},
"title": {
"type": "text"
"type": "search_as_you_type"
},
"author": {
"type": "text"
@ -130,7 +130,7 @@
"type": "date"
},
"siteName": {
"type": "text"
"type": "search_as_you_type"
},
"subscription": {
"type": "keyword",

View file

@ -14,13 +14,14 @@ export const searchStyle = {
}
export const animatorStyle = {
maxWidth: '600px',
width: '100%',
backgroundColor: theme.colors.grayBase.toString(),
color: theme.colors.grayTextContrast.toString(),
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0px 6px 20px rgba(0, 0, 0, 0.2)',
maxWidth: '600px',
borderRadius: '8px',
color: theme.colors.grayTextContrast.toString(),
backgroundColor: theme.colors.grayBase.toString(),
boxShadow: theme.shadows.cardBoxShadow.toString(),
border: `1px solid ${theme.colors.grayBorder.toString()}`,
}
const groupNameStyle = {

View file

@ -47,6 +47,7 @@ import { EditTitleModal } from './EditTitleModal'
import { useGetUserPreferences } from '../../../lib/networking/queries/useGetUserPreferences'
import { searchQuery } from '../../../lib/networking/queries/search'
import debounce from 'lodash/debounce'
import { SearchItem, TypeaheadSearchItemsData, typeaheadSearchQuery } from '../../../lib/networking/queries/typeaheadSearch'
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
@ -66,7 +67,7 @@ const SAVED_SEARCHES: Record<string, string> = {
const fetchSearchResults = async (query: string, cb: any) => {
if (!query.startsWith('#')) return
const res = await searchQuery({ limit: 10, searchQuery: query.substring(1)})
const res = await typeaheadSearchQuery({ limit: 10, searchQuery: query.substring(1)})
cb(res);
};
@ -80,7 +81,7 @@ export function HomeFeedContainer(): JSX.Element {
const { viewerData } = useGetViewerQuery()
const router = useRouter()
const { queryValue } = useKBar((state) => ({queryValue: state.searchQuery}));
const [searchResults, setSearchResults] = useState<LibraryItem[]>([]);
const [searchResults, setSearchResults] = useState<SearchItem[]>([]);
const defaultQuery = {
limit: 10,
@ -121,8 +122,8 @@ export function HomeFeedContainer(): JSX.Element {
useEffect(() => {
if (queryValue.startsWith('#')) {
debouncedFetchSearchResults(queryValue, (data: LibraryItemsData) => {
setSearchResults(data?.search.edges || [])
debouncedFetchSearchResults(queryValue, (data: TypeaheadSearchItemsData) => {
setSearchResults(data?.typeaheadSearch.items || [])
})
}
else setSearchResults([])
@ -506,11 +507,17 @@ export function HomeFeedContainer(): JSX.Element {
]
useRegisterActions(searchResults.map(link => ({
id: link.node.id,
id: link.id,
section: 'Search Results',
name: link.node.title,
keywords: '#' + link.node.title,
perform: () => handleCardAction('showDetail', link),
name: link.title,
keywords: '#' + link.title,
perform: () => {
const username = viewerData?.me?.profile.username
if (username) {
setActiveCardId(link.id)
router.push(`/${username}/${link.slug}`)
}
},
})), [searchResults])
useRegisterActions(activeCardId ? [...ACTIVE_ACTIONS, ...UNACTIVE_ACTIONS] : UNACTIVE_ACTIONS, [activeCardId, activeItem]);

View file

@ -0,0 +1,58 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export type LibraryItemsQueryInput = {
limit?: number
searchQuery?: string
}
export type TypeaheadSearchItemsData = {
typeaheadSearch: SearchItems
}
export type SearchItems = {
items: SearchItem[]
}
export type SearchItem = {
id: string
title: string
slug: string
siteName?: string
}
export async function typeaheadSearchQuery({
limit = 10,
searchQuery,
}: LibraryItemsQueryInput): Promise<TypeaheadSearchItemsData | undefined> {
const query = gql`
query TypeaheadSearch($query: String!, $first: Int) {
typeaheadSearch(query: $query, first: $first) {
... on TypeaheadSearchSuccess {
items {
id
title
slug
siteName
}
}
... on TypeaheadSearchError {
errorCodes
}
}
}
`
const variables = {
first: limit,
query: searchQuery,
}
try {
const data = await gqlFetcher(query, { ...variables })
return (data as TypeaheadSearchItemsData) || undefined
} catch (error) {
console.log('search error', error)
return undefined
}
}

View file

@ -7678,6 +7678,13 @@
dependencies:
"@types/node" "*"
"@types/graphql-fields@^1.3.4":
version "1.3.4"
resolved "https://registry.yarnpkg.com/@types/graphql-fields/-/graphql-fields-1.3.4.tgz#868ffe444ba8027ea1eccb0909f9c331d1bd620a"
integrity sha512-McLJaAaqY7lk9d9y7E61iQrj0AwcEjSb8uHlPh7KgYV+XX1MSLlSt/alhd5k2BPRE8gy/f4lnkLGb5ke3iG66Q==
dependencies:
graphql "^15.3.0"
"@types/hast@^2.0.0":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"
@ -14649,6 +14656,11 @@ graphql-config@^4.1.0:
minimatch "3.0.4"
string-env-interpolation "1.0.1"
graphql-fields@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/graphql-fields/-/graphql-fields-2.0.3.tgz#5e68dff7afbb202be4f4f40623e983b22c96ab8f"
integrity sha512-x3VE5lUcR4XCOxPIqaO4CE+bTK8u6gVouOdpQX9+EKHr+scqtK5Pp/l8nIGqIpN1TUlkKE6jDCCycm/WtLRAwA==
graphql-middleware@^6.0.10:
version "6.1.4"
resolved "https://registry.yarnpkg.com/graphql-middleware/-/graphql-middleware-6.1.4.tgz#1b4dd66195477046282acc8937cb5fca32b6bfd5"