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,