Merge pull request #13950 from backstage/bux/search-per-page-query-param

[Search] Create a per-page query param
This commit is contained in:
Emma Indal
2022-10-04 11:42:13 +02:00
committed by GitHub
15 changed files with 217 additions and 76 deletions
+28
View File
@@ -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
<SearchResults query={{ pageLimit: 30 }}>
{results => {
// Item rendering logic is omitted
}}
</SearchResults>
```
_With context_
```jsx
<SearchContextProvider initialState={{ pageLimit: 30 }}>
<SearchResults>
{results => {
// Item rendering logic is omitted
}}
</SearchResults>
</SearchContextProvider>
```
+5
View File
@@ -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.
+7
View File
@@ -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.
+14
View File
@@ -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.
@@ -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
@@ -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
@@ -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 => {
@@ -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: [
@@ -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;
+2
View File
@@ -102,6 +102,8 @@ export interface SearchQuery {
// (undocumented)
pageCursor?: string;
// (undocumented)
pageLimit?: number;
// (undocumented)
term: string;
// (undocumented)
types?: string[];
+2 -1
View File
@@ -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;
}
+2
View File
@@ -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<React_2.SetStateAction<string>>;
setTypes: React_2.Dispatch<React_2.SetStateAction<string[]>>;
setFilters: React_2.Dispatch<React_2.SetStateAction<JsonObject>>;
setPageLimit: React_2.Dispatch<React_2.SetStateAction<number | undefined>>;
setPageCursor: React_2.Dispatch<React_2.SetStateAction<string | undefined>>;
fetchNextPage?: React_2.DispatchWithoutAction;
fetchPreviousPage?: React_2.DispatchWithoutAction;
@@ -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);
};
@@ -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',
});
});
@@ -44,6 +44,7 @@ export type SearchContextValue = {
setTerm: React.Dispatch<React.SetStateAction<string>>;
setTypes: React.Dispatch<React.SetStateAction<string[]>>;
setFilters: React.Dispatch<React.SetStateAction<JsonObject>>;
setPageLimit: React.Dispatch<React.SetStateAction<number | undefined>>;
setPageCursor: React.Dispatch<React.SetStateAction<string | undefined>>;
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<string>(initialValue.term);
const [types, setTypes] = useState<string[]>(initialValue.types);
const [filters, setFilters] = useState<JsonObject>(initialValue.filters);
const [pageLimit, setPageLimit] = useState<number | undefined>(
initialValue.pageLimit,
);
const [pageCursor, setPageCursor] = useState<string | undefined>(
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,