From b5d1a92fa92dc3edc84133fc1ac265cce780cfc1 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 12 Jul 2022 21:41:40 +0800 Subject: [PATCH 01/14] make title as search_as_you_type type in elastic --- packages/db/elastic_migrations/index_settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/elastic_migrations/index_settings.json b/packages/db/elastic_migrations/index_settings.json index cfdd58756..2342520e2 100644 --- a/packages/db/elastic_migrations/index_settings.json +++ b/packages/db/elastic_migrations/index_settings.json @@ -24,7 +24,7 @@ "type": "keyword" }, "title": { - "type": "text" + "type": "search_as_you_type" }, "author": { "type": "text" From 31ac51112110f99236b8fa48fe99a4eb19275397 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 12 Jul 2022 21:42:13 +0800 Subject: [PATCH 02/14] add typeahead gql schema --- packages/api/src/generated/graphql.ts | 64 +++++++++++++++++++++++ packages/api/src/generated/schema.graphql | 21 ++++++++ packages/api/src/schema.ts | 21 ++++++++ 3 files changed, 106 insertions(+) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 5edea20f8..073ef4242 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1280,6 +1280,7 @@ export type Query = { sendInstallInstructions: SendInstallInstructionsResult; sharedArticle: SharedArticleResult; subscriptions: SubscriptionsResult; + typeahead: TypeaheadResult; user: UserResult; users: UsersResult; validateUsername: Scalars['Boolean']; @@ -1351,6 +1352,11 @@ export type QuerySubscriptionsArgs = { }; +export type QueryTypeaheadArgs = { + query: Scalars['String']; +}; + + export type QueryUserArgs = { userId?: InputMaybe; username?: InputMaybe; @@ -1922,6 +1928,29 @@ export type SubscriptionsSuccess = { subscriptions: Array; }; +export type TypeaheadError = { + __typename?: 'TypeaheadError'; + errorCodes: Array; +}; + +export enum TypeaheadErrorCode { + Unauthorized = 'UNAUTHORIZED' +} + +export type TypeaheadItem = { + __typename?: 'TypeaheadItem'; + id: Scalars['ID']; + slug: Scalars['String']; + title: Scalars['String']; +}; + +export type TypeaheadResult = TypeaheadError | TypeaheadSuccess; + +export type TypeaheadSuccess = { + __typename?: 'TypeaheadSuccess'; + items: Array; +}; + export type UnsubscribeError = { __typename?: 'UnsubscribeError'; errorCodes: Array; @@ -2664,6 +2693,11 @@ export type ResolversTypes = { SubscriptionsErrorCode: SubscriptionsErrorCode; SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess']; SubscriptionsSuccess: ResolverTypeWrapper; + TypeaheadError: ResolverTypeWrapper; + TypeaheadErrorCode: TypeaheadErrorCode; + TypeaheadItem: ResolverTypeWrapper; + TypeaheadResult: ResolversTypes['TypeaheadError'] | ResolversTypes['TypeaheadSuccess']; + TypeaheadSuccess: ResolverTypeWrapper; UnsubscribeError: ResolverTypeWrapper; UnsubscribeErrorCode: UnsubscribeErrorCode; UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess']; @@ -2961,6 +2995,10 @@ export type ResolversParentTypes = { SubscriptionsError: SubscriptionsError; SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess']; SubscriptionsSuccess: SubscriptionsSuccess; + TypeaheadError: TypeaheadError; + TypeaheadItem: TypeaheadItem; + TypeaheadResult: ResolversParentTypes['TypeaheadError'] | ResolversParentTypes['TypeaheadSuccess']; + TypeaheadSuccess: TypeaheadSuccess; UnsubscribeError: UnsubscribeError; UnsubscribeResult: ResolversParentTypes['UnsubscribeError'] | ResolversParentTypes['UnsubscribeSuccess']; UnsubscribeSuccess: UnsubscribeSuccess; @@ -3782,6 +3820,7 @@ export type QueryResolvers; sharedArticle?: Resolver>; subscriptions?: Resolver>; + typeahead?: Resolver>; user?: Resolver>; users?: Resolver; validateUsername?: Resolver>; @@ -4138,6 +4177,27 @@ export type SubscriptionsSuccessResolvers; }; +export type TypeaheadErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TypeaheadItemResolvers = { + id?: Resolver; + slug?: Resolver; + title?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TypeaheadResultResolvers = { + __resolveType: TypeResolveFn<'TypeaheadError' | 'TypeaheadSuccess', ParentType, ContextType>; +}; + +export type TypeaheadSuccessResolvers = { + items?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UnsubscribeErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4574,6 +4634,10 @@ export type Resolvers = { SubscriptionsError?: SubscriptionsErrorResolvers; SubscriptionsResult?: SubscriptionsResultResolvers; SubscriptionsSuccess?: SubscriptionsSuccessResolvers; + TypeaheadError?: TypeaheadErrorResolvers; + TypeaheadItem?: TypeaheadItemResolvers; + TypeaheadResult?: TypeaheadResultResolvers; + TypeaheadSuccess?: TypeaheadSuccessResolvers; UnsubscribeError?: UnsubscribeErrorResolvers; UnsubscribeResult?: UnsubscribeResultResolvers; UnsubscribeSuccess?: UnsubscribeSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 73e5b5bb2..f5fdbb0c7 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -924,6 +924,7 @@ type Query { sendInstallInstructions: SendInstallInstructionsResult! sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult! subscriptions(sort: SortParams): SubscriptionsResult! + typeahead(query: String!): TypeaheadResult! user(userId: ID, username: String): UserResult! users: UsersResult! validateUsername(username: String!): Boolean! @@ -1443,6 +1444,26 @@ type SubscriptionsSuccess { subscriptions: [Subscription!]! } +type TypeaheadError { + errorCodes: [TypeaheadErrorCode!]! +} + +enum TypeaheadErrorCode { + UNAUTHORIZED +} + +type TypeaheadItem { + id: ID! + slug: String! + title: String! +} + +union TypeaheadResult = TypeaheadError | TypeaheadSuccess + +type TypeaheadSuccess { + items: [TypeaheadItem!]! +} + type UnsubscribeError { errorCodes: [UnsubscribeErrorCode!]! } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 6564ef9c4..def1f34a9 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1763,6 +1763,26 @@ const schema = gql` labelIds: [ID!]! } + union TypeaheadResult = TypeaheadSuccess | TypeaheadError + + type TypeaheadSuccess { + items: [TypeaheadItem!]! + } + + type TypeaheadError { + errorCodes: [TypeaheadErrorCode!]! + } + + enum TypeaheadErrorCode { + UNAUTHORIZED + } + + type TypeaheadItem { + id: ID! + title: String! + slug: String! + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1876,6 +1896,7 @@ const schema = gql` webhooks: WebhooksResult! webhook(id: ID!): WebhookResult! apiKeys: ApiKeysResult! + typeahead(query: String!): TypeaheadResult! } ` From dc9523e5228c03aeb7b23f669bff669bfa03a9ca Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 12 Jul 2022 22:39:49 +0800 Subject: [PATCH 03/14] add searchAsYouType in elastic --- packages/api/src/elastic/pages.ts | 47 +++++++++++++++++++++++++ packages/api/test/elastic/index.test.ts | 36 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index d503e83a1..42089781e 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -548,3 +548,50 @@ export const deletePagesByParam = async ( return false } } + +export const searchAsYouType = async ( + userId: string, + query: string, + size = 5 +): Promise => { + try { + const { body } = await client.search>({ + index: INDEX_ALIAS, + body: { + query: { + bool: { + filter: [ + { + term: { + userId, + }, + }, + { + multi_match: { + query, + type: 'bool_prefix', + fields: ['title', 'title._2gram', 'title._3gram'], + }, + }, + ], + }, + }, + _source: ['title', 'slug'], + 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 [] + } +} diff --git a/packages/api/test/elastic/index.test.ts b/packages/api/test/elastic/index.test.ts index 8b6665d02..9c6d90966 100644 --- a/packages/api/test/elastic/index.test.ts +++ b/packages/api/test/elastic/index.test.ts @@ -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') + }) + }) }) From 3a1f38a6f7eafab58ca41758b180bd458f72136a Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 12 Jul 2022 22:57:20 +0800 Subject: [PATCH 04/14] add typeahead api implementation --- packages/api/src/generated/graphql.ts | 1 + packages/api/src/generated/schema.graphql | 2 +- packages/api/src/resolvers/article/index.ts | 27 +++++++++++++++++++ .../api/src/resolvers/function_resolvers.ts | 3 +++ packages/api/src/schema.ts | 2 +- 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 073ef4242..304aeacdb 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1354,6 +1354,7 @@ export type QuerySubscriptionsArgs = { export type QueryTypeaheadArgs = { query: Scalars['String']; + size?: InputMaybe; }; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index f5fdbb0c7..6b405eb34 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -924,7 +924,7 @@ type Query { sendInstallInstructions: SendInstallInstructionsResult! sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult! subscriptions(sort: SortParams): SubscriptionsResult! - typeahead(query: String!): TypeaheadResult! + typeahead(query: String!, size: Int): TypeaheadResult! user(userId: ID, username: String): UserResult! users: UsersResult! validateUsername(username: String!): Boolean! diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 679757f33..beacdd026 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -22,6 +22,7 @@ import { QueryArticleArgs, QueryArticlesArgs, QuerySearchArgs, + QueryTypeaheadArgs, ResolverFn, SaveArticleReadingProgressError, SaveArticleReadingProgressErrorCode, @@ -35,6 +36,9 @@ import { SetShareArticleError, SetShareArticleErrorCode, SetShareArticleSuccess, + TypeaheadError, + TypeaheadErrorCode, + TypeaheadSuccess, } 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 typeaheadResolver = authorized< + TypeaheadSuccess, + TypeaheadError, + QueryTypeaheadArgs +>(async (_obj, { query, size }, { claims }) => { + if (!claims?.uid) { + return { errorCodes: [TypeaheadErrorCode.Unauthorized] } + } + + analytics.track({ + userId: claims.uid, + event: 'typeahead', + properties: { + env: env.server.apiEnv, + query, + size, + }, + }) + + return { items: await searchAsYouType(claims.uid, query, size || undefined) } +}) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index f0aea7a44..73c938da4 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -76,6 +76,7 @@ import { signupResolver, subscribeResolver, subscriptionsResolver, + typeaheadResolver, unsubscribeResolver, updateHighlightResolver, updateLabelResolver, @@ -188,6 +189,7 @@ export const functionResolvers = { webhooks: webhooksResolver, webhook: webhookResolver, apiKeys: apiKeysResolver, + typeahead: typeaheadResolver, }, User: { async sharedArticles( @@ -589,4 +591,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('ApiKeys'), ...resultResolveTypeResolver('RevokeApiKey'), ...resultResolveTypeResolver('DeleteAccount'), + ...resultResolveTypeResolver('Typeahead'), } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index def1f34a9..8afdee59a 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1896,7 +1896,7 @@ const schema = gql` webhooks: WebhooksResult! webhook(id: ID!): WebhookResult! apiKeys: ApiKeysResult! - typeahead(query: String!): TypeaheadResult! + typeahead(query: String!, size: Int): TypeaheadResult! } ` From b68282fe9787d83c6c19d9210711e78d15631db2 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 12 Jul 2022 23:00:31 +0800 Subject: [PATCH 05/14] rename typeahead to typeaheadSearch --- packages/api/src/generated/graphql.ts | 66 +++++++++---------- packages/api/src/generated/schema.graphql | 16 ++--- packages/api/src/resolvers/article/index.ts | 18 ++--- .../api/src/resolvers/function_resolvers.ts | 6 +- packages/api/src/schema.ts | 16 ++--- 5 files changed, 61 insertions(+), 61 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 304aeacdb..e81629d94 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1280,7 +1280,7 @@ export type Query = { sendInstallInstructions: SendInstallInstructionsResult; sharedArticle: SharedArticleResult; subscriptions: SubscriptionsResult; - typeahead: TypeaheadResult; + typeaheadSearch: TypeaheadSearchResult; user: UserResult; users: UsersResult; validateUsername: Scalars['Boolean']; @@ -1352,7 +1352,7 @@ export type QuerySubscriptionsArgs = { }; -export type QueryTypeaheadArgs = { +export type QueryTypeaheadSearchArgs = { query: Scalars['String']; size?: InputMaybe; }; @@ -1929,27 +1929,27 @@ export type SubscriptionsSuccess = { subscriptions: Array; }; -export type TypeaheadError = { - __typename?: 'TypeaheadError'; - errorCodes: Array; +export type TypeaheadSearchError = { + __typename?: 'TypeaheadSearchError'; + errorCodes: Array; }; -export enum TypeaheadErrorCode { +export enum TypeaheadSearchErrorCode { Unauthorized = 'UNAUTHORIZED' } -export type TypeaheadItem = { - __typename?: 'TypeaheadItem'; +export type TypeaheadSearchItem = { + __typename?: 'TypeaheadSearchItem'; id: Scalars['ID']; slug: Scalars['String']; title: Scalars['String']; }; -export type TypeaheadResult = TypeaheadError | TypeaheadSuccess; +export type TypeaheadSearchResult = TypeaheadSearchError | TypeaheadSearchSuccess; -export type TypeaheadSuccess = { - __typename?: 'TypeaheadSuccess'; - items: Array; +export type TypeaheadSearchSuccess = { + __typename?: 'TypeaheadSearchSuccess'; + items: Array; }; export type UnsubscribeError = { @@ -2694,11 +2694,11 @@ export type ResolversTypes = { SubscriptionsErrorCode: SubscriptionsErrorCode; SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess']; SubscriptionsSuccess: ResolverTypeWrapper; - TypeaheadError: ResolverTypeWrapper; - TypeaheadErrorCode: TypeaheadErrorCode; - TypeaheadItem: ResolverTypeWrapper; - TypeaheadResult: ResolversTypes['TypeaheadError'] | ResolversTypes['TypeaheadSuccess']; - TypeaheadSuccess: ResolverTypeWrapper; + TypeaheadSearchError: ResolverTypeWrapper; + TypeaheadSearchErrorCode: TypeaheadSearchErrorCode; + TypeaheadSearchItem: ResolverTypeWrapper; + TypeaheadSearchResult: ResolversTypes['TypeaheadSearchError'] | ResolversTypes['TypeaheadSearchSuccess']; + TypeaheadSearchSuccess: ResolverTypeWrapper; UnsubscribeError: ResolverTypeWrapper; UnsubscribeErrorCode: UnsubscribeErrorCode; UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess']; @@ -2996,10 +2996,10 @@ export type ResolversParentTypes = { SubscriptionsError: SubscriptionsError; SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess']; SubscriptionsSuccess: SubscriptionsSuccess; - TypeaheadError: TypeaheadError; - TypeaheadItem: TypeaheadItem; - TypeaheadResult: ResolversParentTypes['TypeaheadError'] | ResolversParentTypes['TypeaheadSuccess']; - TypeaheadSuccess: TypeaheadSuccess; + TypeaheadSearchError: TypeaheadSearchError; + TypeaheadSearchItem: TypeaheadSearchItem; + TypeaheadSearchResult: ResolversParentTypes['TypeaheadSearchError'] | ResolversParentTypes['TypeaheadSearchSuccess']; + TypeaheadSearchSuccess: TypeaheadSearchSuccess; UnsubscribeError: UnsubscribeError; UnsubscribeResult: ResolversParentTypes['UnsubscribeError'] | ResolversParentTypes['UnsubscribeSuccess']; UnsubscribeSuccess: UnsubscribeSuccess; @@ -3821,7 +3821,7 @@ export type QueryResolvers; sharedArticle?: Resolver>; subscriptions?: Resolver>; - typeahead?: Resolver>; + typeaheadSearch?: Resolver>; user?: Resolver>; users?: Resolver; validateUsername?: Resolver>; @@ -4178,24 +4178,24 @@ export type SubscriptionsSuccessResolvers; }; -export type TypeaheadErrorResolvers = { - errorCodes?: Resolver, ParentType, ContextType>; +export type TypeaheadSearchErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; -export type TypeaheadItemResolvers = { +export type TypeaheadSearchItemResolvers = { id?: Resolver; slug?: Resolver; title?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; -export type TypeaheadResultResolvers = { - __resolveType: TypeResolveFn<'TypeaheadError' | 'TypeaheadSuccess', ParentType, ContextType>; +export type TypeaheadSearchResultResolvers = { + __resolveType: TypeResolveFn<'TypeaheadSearchError' | 'TypeaheadSearchSuccess', ParentType, ContextType>; }; -export type TypeaheadSuccessResolvers = { - items?: Resolver, ParentType, ContextType>; +export type TypeaheadSearchSuccessResolvers = { + items?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -4635,10 +4635,10 @@ export type Resolvers = { SubscriptionsError?: SubscriptionsErrorResolvers; SubscriptionsResult?: SubscriptionsResultResolvers; SubscriptionsSuccess?: SubscriptionsSuccessResolvers; - TypeaheadError?: TypeaheadErrorResolvers; - TypeaheadItem?: TypeaheadItemResolvers; - TypeaheadResult?: TypeaheadResultResolvers; - TypeaheadSuccess?: TypeaheadSuccessResolvers; + TypeaheadSearchError?: TypeaheadSearchErrorResolvers; + TypeaheadSearchItem?: TypeaheadSearchItemResolvers; + TypeaheadSearchResult?: TypeaheadSearchResultResolvers; + TypeaheadSearchSuccess?: TypeaheadSearchSuccessResolvers; UnsubscribeError?: UnsubscribeErrorResolvers; UnsubscribeResult?: UnsubscribeResultResolvers; UnsubscribeSuccess?: UnsubscribeSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 6b405eb34..4137193e1 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -924,7 +924,7 @@ type Query { sendInstallInstructions: SendInstallInstructionsResult! sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult! subscriptions(sort: SortParams): SubscriptionsResult! - typeahead(query: String!, size: Int): TypeaheadResult! + typeaheadSearch(query: String!, size: Int): TypeaheadSearchResult! user(userId: ID, username: String): UserResult! users: UsersResult! validateUsername(username: String!): Boolean! @@ -1444,24 +1444,24 @@ type SubscriptionsSuccess { subscriptions: [Subscription!]! } -type TypeaheadError { - errorCodes: [TypeaheadErrorCode!]! +type TypeaheadSearchError { + errorCodes: [TypeaheadSearchErrorCode!]! } -enum TypeaheadErrorCode { +enum TypeaheadSearchErrorCode { UNAUTHORIZED } -type TypeaheadItem { +type TypeaheadSearchItem { id: ID! slug: String! title: String! } -union TypeaheadResult = TypeaheadError | TypeaheadSuccess +union TypeaheadSearchResult = TypeaheadSearchError | TypeaheadSearchSuccess -type TypeaheadSuccess { - items: [TypeaheadItem!]! +type TypeaheadSearchSuccess { + items: [TypeaheadSearchItem!]! } type UnsubscribeError { diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index beacdd026..c62081c0d 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -22,7 +22,7 @@ import { QueryArticleArgs, QueryArticlesArgs, QuerySearchArgs, - QueryTypeaheadArgs, + QueryTypeaheadSearchArgs, ResolverFn, SaveArticleReadingProgressError, SaveArticleReadingProgressErrorCode, @@ -36,9 +36,9 @@ import { SetShareArticleError, SetShareArticleErrorCode, SetShareArticleSuccess, - TypeaheadError, - TypeaheadErrorCode, - TypeaheadSuccess, + TypeaheadSearchError, + TypeaheadSearchErrorCode, + TypeaheadSearchSuccess, } from '../../generated/graphql' /* eslint-disable @typescript-eslint/no-explicit-any */ import { Merge } from '../../util' @@ -895,13 +895,13 @@ export const searchResolver = authorized< } }) -export const typeaheadResolver = authorized< - TypeaheadSuccess, - TypeaheadError, - QueryTypeaheadArgs +export const typeaheadSearchResolver = authorized< + TypeaheadSearchSuccess, + TypeaheadSearchError, + QueryTypeaheadSearchArgs >(async (_obj, { query, size }, { claims }) => { if (!claims?.uid) { - return { errorCodes: [TypeaheadErrorCode.Unauthorized] } + return { errorCodes: [TypeaheadSearchErrorCode.Unauthorized] } } analytics.track({ diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 73c938da4..0ba6ac410 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -76,7 +76,7 @@ import { signupResolver, subscribeResolver, subscriptionsResolver, - typeaheadResolver, + typeaheadSearchResolver, unsubscribeResolver, updateHighlightResolver, updateLabelResolver, @@ -189,7 +189,7 @@ export const functionResolvers = { webhooks: webhooksResolver, webhook: webhookResolver, apiKeys: apiKeysResolver, - typeahead: typeaheadResolver, + typeaheadSearch: typeaheadSearchResolver, }, User: { async sharedArticles( @@ -591,5 +591,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('ApiKeys'), ...resultResolveTypeResolver('RevokeApiKey'), ...resultResolveTypeResolver('DeleteAccount'), - ...resultResolveTypeResolver('Typeahead'), + ...resultResolveTypeResolver('TypeaheadSearch'), } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 8afdee59a..f81c3cf12 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1763,21 +1763,21 @@ const schema = gql` labelIds: [ID!]! } - union TypeaheadResult = TypeaheadSuccess | TypeaheadError + union TypeaheadSearchResult = TypeaheadSearchSuccess | TypeaheadSearchError - type TypeaheadSuccess { - items: [TypeaheadItem!]! + type TypeaheadSearchSuccess { + items: [TypeaheadSearchItem!]! } - type TypeaheadError { - errorCodes: [TypeaheadErrorCode!]! + type TypeaheadSearchError { + errorCodes: [TypeaheadSearchErrorCode!]! } - enum TypeaheadErrorCode { + enum TypeaheadSearchErrorCode { UNAUTHORIZED } - type TypeaheadItem { + type TypeaheadSearchItem { id: ID! title: String! slug: String! @@ -1896,7 +1896,7 @@ const schema = gql` webhooks: WebhooksResult! webhook(id: ID!): WebhookResult! apiKeys: ApiKeysResult! - typeahead(query: String!, size: Int): TypeaheadResult! + typeaheadSearch(query: String!, size: Int): TypeaheadSearchResult! } ` From 2b94f3ef82966c700f472c46a98edbd34fcd5604 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 12 Jul 2022 23:07:57 +0800 Subject: [PATCH 06/14] add test --- packages/api/test/resolvers/article.test.ts | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index 8353b68ca..7de96be4a 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -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) + }) + }) }) From 7c8fe4ba395ba4a59af56dfc8d5c973edeebf099 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 12 Jul 2022 10:24:29 -0700 Subject: [PATCH 07/14] Update UX of kbar, implement typeahead search --- .../networking/queries/typeaheadSearch.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 packages/web/lib/networking/queries/typeaheadSearch.tsx diff --git a/packages/web/lib/networking/queries/typeaheadSearch.tsx b/packages/web/lib/networking/queries/typeaheadSearch.tsx new file mode 100644 index 000000000..531839174 --- /dev/null +++ b/packages/web/lib/networking/queries/typeaheadSearch.tsx @@ -0,0 +1,57 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' +import { LibraryItemsData } from './useGetLibraryItemsQuery' + +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 +} + +export async function typeaheadSearchQuery({ + limit = 10, + searchQuery, +}: LibraryItemsQueryInput): Promise { + const query = gql` + query TypeaheadSearch($query: String!, $size: Int) { + typeaheadSearch(query: $query, size: $size) { + ... on TypeaheadSearchSuccess { + items { + id + title + slug + } + } + ... 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 + } +} From 91bb08ea5f296689b0d022c9646712f7c043da57 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 12 Jul 2022 10:36:09 -0700 Subject: [PATCH 08/14] Use typeahead search --- packages/web/components/elements/KBar.tsx | 11 +++++---- .../templates/homeFeed/HomeFeedContainer.tsx | 23 ++++++++++++------- yarn.lock | 12 ++++++++++ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/web/components/elements/KBar.tsx b/packages/web/components/elements/KBar.tsx index 81d9c4510..f26b5fb1a 100644 --- a/packages/web/components/elements/KBar.tsx +++ b/packages/web/components/elements/KBar.tsx @@ -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 = { diff --git a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx index 92d5ad30c..ec73a6477 100644 --- a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx +++ b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx @@ -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 = { 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([]); + const [searchResults, setSearchResults] = useState([]); 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]); diff --git a/yarn.lock b/yarn.lock index 45ca87bfb..9412790f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From 230df45ba11addeaf67d117da34db4a74715939c Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 13 Jul 2022 10:36:29 +0800 Subject: [PATCH 09/14] make siteName as search_as_you_type --- packages/db/elastic_migrations/index_settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/elastic_migrations/index_settings.json b/packages/db/elastic_migrations/index_settings.json index 2342520e2..03035a815 100644 --- a/packages/db/elastic_migrations/index_settings.json +++ b/packages/db/elastic_migrations/index_settings.json @@ -130,7 +130,7 @@ "type": "date" }, "siteName": { - "type": "text" + "type": "search_as_you_type" }, "subscription": { "type": "keyword", From 931c519d448434268cb8f49f831f2e60c8601df9 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 13 Jul 2022 10:37:06 +0800 Subject: [PATCH 10/14] typeahead searchs by siteName --- packages/api/src/elastic/pages.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index 42089781e..3f0b1538f 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -570,13 +570,20 @@ export const searchAsYouType = async ( multi_match: { query, type: 'bool_prefix', - fields: ['title', 'title._2gram', 'title._3gram'], + fields: [ + 'title', + 'title._2gram', + 'title._3gram', + 'siteName', + 'siteName._2gram', + 'siteName._3gram', + ], }, }, ], }, }, - _source: ['title', 'slug'], + _source: ['title', 'slug', 'siteName'], size, }, }) From 970609c3b8780f3c9bafad6dcab9672d0dd41be2 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 13 Jul 2022 10:46:03 +0800 Subject: [PATCH 11/14] change size to first in query param --- packages/api/src/generated/graphql.ts | 2 +- packages/api/src/generated/schema.graphql | 2 +- packages/api/src/resolvers/article/index.ts | 6 +++--- packages/api/src/schema.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index e81629d94..58c3654be 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1353,8 +1353,8 @@ export type QuerySubscriptionsArgs = { export type QueryTypeaheadSearchArgs = { + first?: InputMaybe; query: Scalars['String']; - size?: InputMaybe; }; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 4137193e1..2baacb43d 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -924,7 +924,7 @@ type Query { sendInstallInstructions: SendInstallInstructionsResult! sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult! subscriptions(sort: SortParams): SubscriptionsResult! - typeaheadSearch(query: String!, size: Int): TypeaheadSearchResult! + typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult! user(userId: ID, username: String): UserResult! users: UsersResult! validateUsername(username: String!): Boolean! diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index c62081c0d..82ce71598 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -899,7 +899,7 @@ export const typeaheadSearchResolver = authorized< TypeaheadSearchSuccess, TypeaheadSearchError, QueryTypeaheadSearchArgs ->(async (_obj, { query, size }, { claims }) => { +>(async (_obj, { query, first }, { claims }) => { if (!claims?.uid) { return { errorCodes: [TypeaheadSearchErrorCode.Unauthorized] } } @@ -910,9 +910,9 @@ export const typeaheadSearchResolver = authorized< properties: { env: env.server.apiEnv, query, - size, + first, }, }) - return { items: await searchAsYouType(claims.uid, query, size || undefined) } + return { items: await searchAsYouType(claims.uid, query, first || undefined) } }) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index f81c3cf12..4914115a8 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1896,7 +1896,7 @@ const schema = gql` webhooks: WebhooksResult! webhook(id: ID!): WebhookResult! apiKeys: ApiKeysResult! - typeaheadSearch(query: String!, size: Int): TypeaheadSearchResult! + typeaheadSearch(query: String!, first: Int): TypeaheadSearchResult! } ` From 3a31757c59138e42094ea9ed529a0d58417bf874 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 13 Jul 2022 10:51:57 +0800 Subject: [PATCH 12/14] change size to first in query param in web --- .../web/lib/networking/queries/typeaheadSearch.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/web/lib/networking/queries/typeaheadSearch.tsx b/packages/web/lib/networking/queries/typeaheadSearch.tsx index 531839174..6df59e302 100644 --- a/packages/web/lib/networking/queries/typeaheadSearch.tsx +++ b/packages/web/lib/networking/queries/typeaheadSearch.tsx @@ -1,6 +1,5 @@ import { gql } from 'graphql-request' import { gqlFetcher } from '../networkHelpers' -import { LibraryItemsData } from './useGetLibraryItemsQuery' export type LibraryItemsQueryInput = { limit?: number @@ -25,9 +24,9 @@ export async function typeaheadSearchQuery({ limit = 10, searchQuery, }: LibraryItemsQueryInput): Promise { - const query = gql` - query TypeaheadSearch($query: String!, $size: Int) { - typeaheadSearch(query: $query, size: $size) { + const query = gql` + query TypeaheadSearch($query: String!, $first: Int) { + typeaheadSearch(query: $query, first: $first) { ... on TypeaheadSearchSuccess { items { id @@ -48,8 +47,8 @@ export async function typeaheadSearchQuery({ } try { - const data = (await gqlFetcher(query, {...variables})) - return data as TypeaheadSearchItemsData || undefined; + const data = await gqlFetcher(query, { ...variables }) + return (data as TypeaheadSearchItemsData) || undefined } catch (error) { console.log('search error', error) return undefined From cb1d64cd82aa20e36f2158a73518e78de1020cdc Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 13 Jul 2022 10:57:54 +0800 Subject: [PATCH 13/14] add siteName in api response --- packages/api/src/generated/graphql.ts | 2 ++ packages/api/src/generated/schema.graphql | 1 + packages/api/src/schema.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 58c3654be..2beab998c 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1941,6 +1941,7 @@ export enum TypeaheadSearchErrorCode { export type TypeaheadSearchItem = { __typename?: 'TypeaheadSearchItem'; id: Scalars['ID']; + siteName?: Maybe; slug: Scalars['String']; title: Scalars['String']; }; @@ -4185,6 +4186,7 @@ export type TypeaheadSearchErrorResolvers = { id?: Resolver; + siteName?: Resolver, ParentType, ContextType>; slug?: Resolver; title?: Resolver; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 2baacb43d..187b8d072 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1454,6 +1454,7 @@ enum TypeaheadSearchErrorCode { type TypeaheadSearchItem { id: ID! + siteName: String slug: String! title: String! } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 4914115a8..9dea9b1ec 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1781,6 +1781,7 @@ const schema = gql` id: ID! title: String! slug: String! + siteName: String } # Mutations From b6826a8821fd8c8e013d29e43cf656aa3d842a74 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 13 Jul 2022 11:03:33 +0800 Subject: [PATCH 14/14] add siteName in api response in web --- packages/web/lib/networking/queries/typeaheadSearch.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/lib/networking/queries/typeaheadSearch.tsx b/packages/web/lib/networking/queries/typeaheadSearch.tsx index 6df59e302..bfa830e74 100644 --- a/packages/web/lib/networking/queries/typeaheadSearch.tsx +++ b/packages/web/lib/networking/queries/typeaheadSearch.tsx @@ -18,6 +18,7 @@ export type SearchItem = { id: string title: string slug: string + siteName?: string } export async function typeaheadSearchQuery({ @@ -32,6 +33,7 @@ export async function typeaheadSearchQuery({ id title slug + siteName } } ... on TypeaheadSearchError {