Merge pull request #13950 from backstage/bux/search-per-page-query-param
[Search] Create a per-page query param
This commit is contained in:
@@ -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>
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface SearchQuery {
|
||||
// (undocumented)
|
||||
pageCursor?: string;
|
||||
// (undocumented)
|
||||
pageLimit?: number;
|
||||
// (undocumented)
|
||||
term: string;
|
||||
// (undocumented)
|
||||
types?: string[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user