diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index 60bb5da89..d6307007d 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -15,6 +15,7 @@ import { InFilter, LabelFilter, LabelFilterType, + NoFilter, ReadFilter, SortBy, SortOrder, @@ -114,16 +115,15 @@ const appendExcludeLabelFilter = ( body: SearchBody, filters: LabelFilter[] ): void => { + const labels = filters.map((filter) => filter.labels).flat() body.query.bool.must_not.push({ nested: { path: 'labels', - query: filters.map((filter) => { - return { - terms: { - 'labels.name': filter.labels, - }, - } - }), + query: { + terms: { + 'labels.name': labels, + }, + }, }, }) } @@ -209,6 +209,21 @@ const appendRecommendedBy = (body: SearchBody, recommendedBy: string): void => { }) } +const appendNoFilters = (body: SearchBody, noFilters: NoFilter[]): void => { + noFilters.forEach((filter) => { + body.query.bool.must_not.push({ + nested: { + path: filter.field, + query: { + exists: { + field: filter.field, + }, + }, + }, + }) + }) +} + export const createPage = async ( page: Page, ctx: PageContext @@ -390,6 +405,7 @@ export const searchPages = async ( matchFilters, ids, includeContent, + noFilters, } = args // default order is descending const sortOrder = sort?.order || SortOrder.DESCENDING @@ -485,6 +501,10 @@ export const searchPages = async ( }) } + if (noFilters) { + appendNoFilters(body, noFilters) + } + console.log('searching pages in elastic', JSON.stringify(body)) const response = await client.search, SearchBody>({ diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts index 717ace7ce..39cdadba2 100644 --- a/packages/api/src/elastic/types.ts +++ b/packages/api/src/elastic/types.ts @@ -7,6 +7,7 @@ import { HasFilter, InFilter, LabelFilter, + NoFilter, ReadFilter, SortParams, } from '../utils/search' @@ -103,7 +104,17 @@ export interface SearchBody { terms: { 'labels.name': string[] } - }[] + } + } + } + | { + nested: { + path: string + query: { + exists: { + field: string + } + } } } )[] @@ -317,4 +328,5 @@ export interface PageSearchArgs { ids?: string[] recommendedBy?: string includeContent?: boolean + noFilters?: NoFilter[] } diff --git a/packages/api/src/utils/search.ts b/packages/api/src/utils/search.ts index 05c2cc3fe..b98ff016c 100644 --- a/packages/api/src/utils/search.ts +++ b/packages/api/src/utils/search.ts @@ -36,6 +36,7 @@ export interface SearchFilter { matchFilters: FieldFilter[] ids: string[] recommendedBy?: string + noFilters: NoFilter[] } export enum LabelFilterType { @@ -83,6 +84,10 @@ export interface FieldFilter { value: string } +export interface NoFilter { + field: string +} + const parseRecommendedBy = (str?: string): string | undefined => { if (str === undefined) { return undefined @@ -263,6 +268,22 @@ const parseIds = (field: string, str?: string): string[] | undefined => { return str.split(',') } +const parseNoFilter = (str?: string): NoFilter | undefined => { + if (str === undefined) { + return undefined + } + + const strLower = str.toLowerCase() + const accepted = ['highlight', 'label'] + if (accepted.includes(strLower)) { + return { + field: `${strLower}s`, + } + } + + return undefined +} + export const parseSearchQuery = (query: string | undefined): SearchFilter => { const searchQuery = query ? query.replace(/\W\s":/g, '') : undefined const result: SearchFilter = { @@ -275,6 +296,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => { termFilters: [], matchFilters: [], ids: [], + noFilters: [], } if (!searchQuery) { @@ -288,6 +310,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => { termFilters: [], matchFilters: [], ids: [], + noFilters: [], } } @@ -310,6 +333,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => { 'updated', 'includes', 'recommendedBy', + 'no', ], tokenize: true, }) @@ -395,6 +419,11 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => { result.recommendedBy = parseRecommendedBy(keyword.value) break } + case 'no': { + const noFilter = parseNoFilter(keyword.value) + noFilter && result.noFilters.push(noFilter) + break + } } } } diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index 77fd10fad..a7c3caff2 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -810,7 +810,7 @@ describe('Article API', () => { userId: user.id, pageType: PageType.Article, title: 'test title', - content: '

search page

', + content: '

test search api

', slug: 'test slug', createdAt: new Date(), updatedAt: new Date(), @@ -849,7 +849,7 @@ describe('Article API', () => { context('when we search for a keyword', () => { before(() => { - keyword = 'search' + keyword = 'search api' }) it('saves the term in search history', async () => { @@ -875,7 +875,7 @@ describe('Article API', () => { context('when type:highlights is not in the query', () => { before(() => { - keyword = 'search' + keyword = 'search api' }) it('should return pages in descending order', async () => { @@ -901,7 +901,7 @@ describe('Article API', () => { context('when type:highlights is in the query', () => { before(() => { - keyword = 'search type:highlights' + keyword = "'search api' type:highlights" }) it('should return highlights in descending order', async () => { @@ -918,7 +918,7 @@ describe('Article API', () => { context('when is:unread is in the query', () => { before(() => { - keyword = 'search is:unread' + keyword = "'search api' is:unread" }) it('should return unread articles in descending order', async () => { @@ -932,6 +932,30 @@ describe('Article API', () => { expect(res.body.data.search.edges[4].node.id).to.eq(pages[0].id) }) }) + + context('when no:label is in the query', () => { + before(async () => { + keyword = "'search api' no:label" + }) + + it('returns non-labeled items in descending order', async () => { + const res = await graphqlRequest(query, authToken).expect(200) + + expect(res.body.data.search.pageInfo.totalCount).to.eq(5) + }) + }) + + context('when no:highlight is in the query', () => { + before(async () => { + keyword = "'search api' no:highlight" + }) + + it('returns non-highlighted items in descending order', async () => { + const res = await graphqlRequest(query, authToken).expect(200) + + expect(res.body.data.search.pageInfo.totalCount).to.eq(0) + }) + }) }) describe('TypeaheadSearch API', () => {