diff --git a/.changeset/search-bobcats-love.md b/.changeset/search-bobcats-love.md new file mode 100644 index 0000000000..f562961c1b --- /dev/null +++ b/.changeset/search-bobcats-love.md @@ -0,0 +1,28 @@ +--- +'@backstage/plugin-search-react': minor +--- + +The search query state now has an optional `pageLimit` property that determines how many results will be requested per page, it defaults to 25. + +Examples: +_Basic_ + +```jsx + + {results => { + // Item rendering logic is omitted + }} + +``` + +_With context_ + +```jsx + + + {results => { + // Item rendering logic is omitted + }} + + +``` diff --git a/.changeset/search-cars-hide.md b/.changeset/search-cars-hide.md new file mode 100644 index 0000000000..b89662e90a --- /dev/null +++ b/.changeset/search-cars-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-common': minor +--- + +There is a new property called `pageLimit` on the `SearchQuery` interface that specifies how many results should be returned per page. diff --git a/.changeset/search-cycles-sniff.md b/.changeset/search-cycles-sniff.md new file mode 100644 index 0000000000..350e499a18 --- /dev/null +++ b/.changeset/search-cycles-sniff.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-search-backend-node': patch +'@backstage/plugin-search-backend-module-pg': patch +'@backstage/plugin-search-backend-module-elasticsearch': patch +--- + +The search engine has been updated to take advantage of the `pageLimit` property on search queries. If none is provided, the search engine will continue to use its default value of 25 results per page. diff --git a/.changeset/search-rats-grin.md b/.changeset/search-rats-grin.md new file mode 100644 index 0000000000..7b561d9c19 --- /dev/null +++ b/.changeset/search-rats-grin.md @@ -0,0 +1,14 @@ +--- +'@backstage/plugin-search-backend': minor +--- + +The query received by search engines now contains a property called `pageLimit`, it specifies how many results to return per page when sending a query request to the search backend. + +Example: +_Returns up to 30 results per page_ + +``` +GET /query?pageLimit=30 +``` + +The search backend validates the page limit and this value must not exceed 100, but it doesn't set a default value for the page limit parameter, it leaves it up to each search engine to set this, so Lunr, Postgres and Elastic Search set 25 results per page as a default value. diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts index f3107df1fd..a33d8939eb 100644 --- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts +++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts @@ -210,7 +210,7 @@ export class ElasticSearchSearchEngine implements SearchEngine { .multiMatchQuery(['*'], term) .fuzziness('auto') .minimumShouldMatch(1); - const pageSize = 25; + const pageSize = query.pageLimit || 25; const { page } = decodePageCursor(pageCursor); let esbRequestBodySearch = esb diff --git a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts index 01fe0fff82..f53c433c2f 100644 --- a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts +++ b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts @@ -137,7 +137,7 @@ export class PgSearchEngine implements SearchEngine { query: SearchQuery, options: PgSearchQueryTranslatorOptions, ): ConcretePgSearchQuery { - const pageSize = 25; + const pageSize = query.pageLimit || 25; const { page } = decodePageCursor(query.pageCursor); const offset = page * pageSize; // We request more result to know whether there is another page diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts index d65aa58b9d..d60f9951f4 100644 --- a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts +++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts @@ -71,8 +71,9 @@ export class LunrSearchEngine implements SearchEngine { term, filters, types, + pageLimit, }: SearchQuery): ConcreteLunrQuery => { - const pageSize = 25; + const pageSize = pageLimit || 25; return { lunrQueryBuilder: q => { diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index f26f191ecd..c3eb6ea00f 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -112,6 +112,37 @@ describe('createRouter', () => { }); }); + it('should accept per page value under or equal to 100', async () => { + const response = await request(app).get(`/query?pageLimit=30`); + + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + results: [], + }); + }); + + it('should reject per page value over 100', async () => { + const response = await request(app).get(`/query?pageLimit=200`); + + expect(response.status).toEqual(400); + expect(response.body).toMatchObject({ + error: { + message: /The page limit "200" is greater than "100"/i, + }, + }); + }); + + it('should reject a non number per page value', async () => { + const response = await request(app).get(`/query?pageLimit=twohundred`); + + expect(response.status).toEqual(400); + expect(response.body).toMatchObject({ + error: { + message: /The page limit "twohundred" is not a number"/i, + }, + }); + }); + it('removes backend-only properties from search documents', async () => { mockSearchEngine.query.mockResolvedValue({ results: [ diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 87da80d8f5..5ad4d4969b 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -62,6 +62,7 @@ export type RouterOptions = { logger: Logger; }; +const maxPageLimit = 100; const allowedLocationProtocols = ['http:', 'https:']; /** @@ -79,6 +80,22 @@ export async function createRouter( .array(z.string().refine(type => Object.keys(types).includes(type))) .optional(), pageCursor: z.string().optional(), + pageLimit: z + .string() + .transform(pageLimit => parseInt(pageLimit, 10)) + .refine( + pageLimit => !isNaN(pageLimit), + pageLimit => ({ + message: `The page limit "${pageLimit}" is not a number`, + }), + ) + .refine( + pageLimit => pageLimit <= maxPageLimit, + pageLimit => ({ + message: `The page limit "${pageLimit}" is greater than "${maxPageLimit}"`, + }), + ) + .optional(), }); let permissionEvaluator: PermissionEvaluator; diff --git a/plugins/search-common/api-report.md b/plugins/search-common/api-report.md index ea9fc2d26e..61e5d22257 100644 --- a/plugins/search-common/api-report.md +++ b/plugins/search-common/api-report.md @@ -102,6 +102,8 @@ export interface SearchQuery { // (undocumented) pageCursor?: string; // (undocumented) + pageLimit?: number; + // (undocumented) term: string; // (undocumented) types?: string[]; diff --git a/plugins/search-common/src/types.ts b/plugins/search-common/src/types.ts index 4b4c25d423..54abc9e1b1 100644 --- a/plugins/search-common/src/types.ts +++ b/plugins/search-common/src/types.ts @@ -23,8 +23,9 @@ import { Readable, Transform, Writable } from 'stream'; */ export interface SearchQuery { term: string; - filters?: JsonObject; types?: string[]; + filters?: JsonObject; + pageLimit?: number; pageCursor?: string; } diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index 1160675c5a..ddfe5d5b39 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -165,6 +165,7 @@ export type SearchContextState = { term: string; types: string[]; filters: JsonObject; + pageLimit?: number; pageCursor?: string; }; @@ -174,6 +175,7 @@ export type SearchContextValue = { setTerm: React_2.Dispatch>; setTypes: React_2.Dispatch>; setFilters: React_2.Dispatch>; + setPageLimit: React_2.Dispatch>; setPageCursor: React_2.Dispatch>; fetchNextPage?: React_2.DispatchWithoutAction; fetchPreviousPage?: React_2.DispatchWithoutAction; diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.tsx index 40c60c3263..dd11f42903 100644 --- a/plugins/search-react/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.tsx @@ -98,16 +98,10 @@ export const SearchResultApi = (props: SearchResultApiProps) => { const { query, children } = props; const searchApi = useApi(searchApiRef); - const state = useAsync( - () => - searchApi.query({ - term: query.term ?? '', - types: query.types ?? [], - filters: query.filters ?? {}, - pageCursor: query.pageCursor, - }), - [query], - ); + const state = useAsync(() => { + const { term = '', types = [], filters = {}, ...rest } = query; + return searchApi.query({ ...rest, term, types, filters }); + }, [query]); return children(state); }; diff --git a/plugins/search-react/src/context/SearchContext.test.tsx b/plugins/search-react/src/context/SearchContext.test.tsx index b5888fa8a9..edee2f153f 100644 --- a/plugins/search-react/src/context/SearchContext.test.tsx +++ b/plugins/search-react/src/context/SearchContext.test.tsx @@ -40,8 +40,8 @@ describe('SearchContext', () => { const initialState = { term: '', - filters: {}, types: ['*'], + filters: {}, }; beforeEach(() => { @@ -167,8 +167,8 @@ describe('SearchContext', () => { initialProps: { initialState: { ...initialState, - filters: { foo: 'bar' }, term: 'first term', + filters: { foo: 'bar' }, pageCursor: 'SOMEPAGE', }, }, @@ -194,8 +194,8 @@ describe('SearchContext', () => { initialProps: { initialState: { ...initialState, - filters: { foo: 'bar' }, term: 'first term', + filters: { foo: 'bar' }, pageCursor: 'SOMEPAGE', }, }, @@ -236,58 +236,9 @@ describe('SearchContext', () => { await waitForNextUpdate(); expect(query).toHaveBeenLastCalledWith({ - filters: {}, - types: ['*'], term, - }); - }); - - it('When filters are set', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - const filters = { filter: 'filter' }; - - act(() => { - result.current.setFilters(filters); - }); - - await waitForNextUpdate(); - - expect(query).toHaveBeenLastCalledWith({ - filters, types: ['*'], - term: '', - }); - }); - - it('When page is set', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, - }); - - await waitForNextUpdate(); - - act(() => { - result.current.setPageCursor('SOMEPAGE'); - }); - - await waitForNextUpdate(); - - expect(query).toHaveBeenLastCalledWith({ filters: {}, - types: ['*'], - pageCursor: 'SOMEPAGE', - term: '', }); }); @@ -311,8 +262,85 @@ describe('SearchContext', () => { expect(query).toHaveBeenLastCalledWith({ types, - filters: {}, term: '', + filters: {}, + }); + }); + + it('When filters are set', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + const filters = { filter: 'filter' }; + + act(() => { + result.current.setFilters(filters); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + filters, + term: '', + types: ['*'], + }); + }); + + it('When page limit is set', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + const pageLimit = 30; + + act(() => { + result.current.setPageLimit(pageLimit); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + pageLimit, + term: '', + types: ['*'], + filters: {}, + }); + }); + + it('When page cursor is set', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); + + await waitForNextUpdate(); + + const pageCursor = 'SOMEPAGE'; + + act(() => { + result.current.setPageCursor(pageCursor); + }); + + await waitForNextUpdate(); + + expect(query).toHaveBeenLastCalledWith({ + pageCursor, + term: '', + types: ['*'], + filters: {}, }); }); @@ -341,9 +369,9 @@ describe('SearchContext', () => { await waitForNextUpdate(); expect(query).toHaveBeenLastCalledWith({ + term: '', types: ['*'], filters: {}, - term: '', pageCursor: 'NEXT', }); }); @@ -373,9 +401,9 @@ describe('SearchContext', () => { await waitForNextUpdate(); expect(query).toHaveBeenLastCalledWith({ + term: '', types: ['*'], filters: {}, - term: '', pageCursor: 'PREVIOUS', }); }); diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index 6557f04c15..94e51c0668 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -44,6 +44,7 @@ export type SearchContextValue = { setTerm: React.Dispatch>; setTypes: React.Dispatch>; setFilters: React.Dispatch>; + setPageLimit: React.Dispatch>; setPageCursor: React.Dispatch>; fetchNextPage?: React.DispatchWithoutAction; fetchPreviousPage?: React.DispatchWithoutAction; @@ -57,6 +58,7 @@ export type SearchContextState = { term: string; types: string[]; filters: JsonObject; + pageLimit?: number; pageCursor?: string; }; @@ -98,9 +100,10 @@ export const useSearchContextCheck = () => { */ const searchInitialState: SearchContextState = { term: '', - pageCursor: undefined, - filters: {}, types: [], + filters: {}, + pageLimit: undefined, + pageCursor: undefined, }; const useSearchContextValue = ( @@ -111,6 +114,9 @@ const useSearchContextValue = ( const [term, setTerm] = useState(initialValue.term); const [types, setTypes] = useState(initialValue.types); const [filters, setFilters] = useState(initialValue.filters); + const [pageLimit, setPageLimit] = useState( + initialValue.pageLimit, + ); const [pageCursor, setPageCursor] = useState( initialValue.pageCursor, ); @@ -122,20 +128,23 @@ const useSearchContextValue = ( () => searchApi.query({ term, - filters, - pageCursor, types, + filters, + pageLimit, + pageCursor, }), - [term, types, filters, pageCursor], + [term, types, filters, pageLimit, pageCursor], ); const hasNextPage = !result.loading && !result.error && result.value?.nextPageCursor; const hasPreviousPage = !result.loading && !result.error && result.value?.previousPageCursor; + const fetchNextPage = useCallback(() => { setPageCursor(result.value?.nextPageCursor); }, [result.value?.nextPageCursor]); + const fetchPreviousPage = useCallback(() => { setPageCursor(result.value?.previousPageCursor); }, [result.value?.previousPageCursor]); @@ -158,12 +167,14 @@ const useSearchContextValue = ( const value: SearchContextValue = { result, - filters, - setFilters, term, setTerm, types, setTypes, + filters, + setFilters, + pageLimit, + setPageLimit, pageCursor, setPageCursor, fetchNextPage: hasNextPage ? fetchNextPage : undefined,