diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index d503e83a1..3f0b1538f 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -548,3 +548,57 @@ 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', + '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 [] + } +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 5edea20f8..2beab998c 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; + typeaheadSearch: TypeaheadSearchResult; user: UserResult; users: UsersResult; validateUsername: Scalars['Boolean']; @@ -1351,6 +1352,12 @@ export type QuerySubscriptionsArgs = { }; +export type QueryTypeaheadSearchArgs = { + first?: InputMaybe; + query: Scalars['String']; +}; + + export type QueryUserArgs = { userId?: InputMaybe; username?: InputMaybe; @@ -1922,6 +1929,30 @@ export type SubscriptionsSuccess = { subscriptions: Array; }; +export type TypeaheadSearchError = { + __typename?: 'TypeaheadSearchError'; + errorCodes: Array; +}; + +export enum TypeaheadSearchErrorCode { + Unauthorized = 'UNAUTHORIZED' +} + +export type TypeaheadSearchItem = { + __typename?: 'TypeaheadSearchItem'; + id: Scalars['ID']; + siteName?: Maybe; + slug: Scalars['String']; + title: Scalars['String']; +}; + +export type TypeaheadSearchResult = TypeaheadSearchError | TypeaheadSearchSuccess; + +export type TypeaheadSearchSuccess = { + __typename?: 'TypeaheadSearchSuccess'; + items: Array; +}; + export type UnsubscribeError = { __typename?: 'UnsubscribeError'; errorCodes: Array; @@ -2664,6 +2695,11 @@ export type ResolversTypes = { SubscriptionsErrorCode: SubscriptionsErrorCode; SubscriptionsResult: ResolversTypes['SubscriptionsError'] | ResolversTypes['SubscriptionsSuccess']; SubscriptionsSuccess: ResolverTypeWrapper; + TypeaheadSearchError: ResolverTypeWrapper; + TypeaheadSearchErrorCode: TypeaheadSearchErrorCode; + TypeaheadSearchItem: ResolverTypeWrapper; + TypeaheadSearchResult: ResolversTypes['TypeaheadSearchError'] | ResolversTypes['TypeaheadSearchSuccess']; + TypeaheadSearchSuccess: ResolverTypeWrapper; UnsubscribeError: ResolverTypeWrapper; 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; sharedArticle?: Resolver>; subscriptions?: Resolver>; + typeaheadSearch?: Resolver>; user?: Resolver>; users?: Resolver; validateUsername?: Resolver>; @@ -4138,6 +4179,28 @@ export type SubscriptionsSuccessResolvers; }; +export type TypeaheadSearchErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TypeaheadSearchItemResolvers = { + id?: Resolver; + siteName?: Resolver, ParentType, ContextType>; + slug?: Resolver; + title?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TypeaheadSearchResultResolvers = { + __resolveType: TypeResolveFn<'TypeaheadSearchError' | 'TypeaheadSearchSuccess', ParentType, ContextType>; +}; + +export type TypeaheadSearchSuccessResolvers = { + items?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UnsubscribeErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4574,6 +4637,10 @@ export type Resolvers = { SubscriptionsError?: SubscriptionsErrorResolvers; SubscriptionsResult?: SubscriptionsResultResolvers; SubscriptionsSuccess?: SubscriptionsSuccessResolvers; + 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 73e5b5bb2..187b8d072 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! + 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!]! } diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 679757f33..82ce71598 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, + 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) } +}) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index f0aea7a44..0ba6ac410 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, + 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'), } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 6564ef9c4..9dea9b1ec 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -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! } ` 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') + }) + }) }) 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) + }) + }) }) diff --git a/packages/db/elastic_migrations/index_settings.json b/packages/db/elastic_migrations/index_settings.json index cfdd58756..03035a815 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" @@ -130,7 +130,7 @@ "type": "date" }, "siteName": { - "type": "text" + "type": "search_as_you_type" }, "subscription": { "type": "keyword", 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/packages/web/lib/networking/queries/typeaheadSearch.tsx b/packages/web/lib/networking/queries/typeaheadSearch.tsx new file mode 100644 index 000000000..bfa830e74 --- /dev/null +++ b/packages/web/lib/networking/queries/typeaheadSearch.tsx @@ -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 { + 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 + } +} 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"