Implement offset and limit based paging in frontend
Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
@@ -14,20 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core';
|
||||
import Pagination from '@material-ui/lab/Pagination';
|
||||
import { Content, Header, Lifecycle, Page } from '@backstage/core-components';
|
||||
import { CatalogResultListItem } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
DefaultResultListItem,
|
||||
SearchBar,
|
||||
SearchFilter,
|
||||
SearchResult,
|
||||
SearchType,
|
||||
DefaultResultListItem,
|
||||
} from '@backstage/plugin-search';
|
||||
import { Content, Header, Lifecycle, Page } from '@backstage/core-components';
|
||||
import { DocsResultListItem } from '@backstage/plugin-techdocs';
|
||||
import { SearchResultSet } from '@backstage/search-common';
|
||||
import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
bar: {
|
||||
@@ -43,59 +41,6 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// TODO: Move this into the search plugin once pagination is natively supported.
|
||||
// See: https://github.com/backstage/backstage/issues/6062
|
||||
const SearchResultList = ({ results }: SearchResultSet) => {
|
||||
const pageSize = 10;
|
||||
const [page, setPage] = useState(1);
|
||||
const changePage = (_: any, pageIndex: number) => {
|
||||
setPage(pageIndex);
|
||||
};
|
||||
const pageAmount = Math.ceil((results.length || 0) / pageSize);
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{results
|
||||
.slice(pageSize * (page - 1), pageSize * page)
|
||||
.map(({ type, document }) => {
|
||||
switch (type) {
|
||||
case 'software-catalog':
|
||||
return (
|
||||
<CatalogResultListItem
|
||||
key={document.location}
|
||||
result={document}
|
||||
/>
|
||||
);
|
||||
case 'techdocs':
|
||||
return (
|
||||
<DocsResultListItem
|
||||
key={document.location}
|
||||
result={document}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DefaultResultListItem
|
||||
key={document.location}
|
||||
result={document}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</List>
|
||||
{pageAmount > 1 && (
|
||||
<Pagination
|
||||
count={pageAmount}
|
||||
page={page}
|
||||
onChange={changePage}
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchPage = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
@@ -129,7 +74,35 @@ const SearchPage = () => {
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<SearchResult>
|
||||
{({ results }) => <SearchResultList results={results} />}
|
||||
{({ results }) => (
|
||||
<List>
|
||||
{results.map(({ type, document }) => {
|
||||
switch (type) {
|
||||
case 'software-catalog':
|
||||
return (
|
||||
<CatalogResultListItem
|
||||
key={document.location}
|
||||
result={document}
|
||||
/>
|
||||
);
|
||||
case 'techdocs':
|
||||
return (
|
||||
<DocsResultListItem
|
||||
key={document.location}
|
||||
result={document}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DefaultResultListItem
|
||||
key={document.location}
|
||||
result={document}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</SearchResult>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('apis', () => {
|
||||
term: '',
|
||||
filters: {},
|
||||
types: [],
|
||||
pageCursor: '',
|
||||
page: {},
|
||||
};
|
||||
|
||||
const baseUrl = 'https://base-url.com/';
|
||||
@@ -53,7 +53,7 @@ describe('apis', () => {
|
||||
it('Fetch is called with expected URL (including stringified Q params)', async () => {
|
||||
await client.query(query);
|
||||
expect(getBaseUrl).toHaveBeenLastCalledWith('search/query');
|
||||
expect(fetch).toHaveBeenLastCalledWith(`${baseUrl}?term=&pageCursor=`, {
|
||||
expect(fetch).toHaveBeenLastCalledWith(`${baseUrl}?term=`, {
|
||||
headers: {},
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe('apis', () => {
|
||||
});
|
||||
await authedClient.query(query);
|
||||
expect(getBaseUrl).toHaveBeenLastCalledWith('search/query');
|
||||
expect(fetch).toHaveBeenLastCalledWith(`${baseUrl}?term=&pageCursor=`, {
|
||||
expect(fetch).toHaveBeenLastCalledWith(`${baseUrl}?term=`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ jest.mock('@backstage/core-plugin-api', () => ({
|
||||
describe('SearchBar', () => {
|
||||
const initialState = {
|
||||
term: '',
|
||||
pageCursor: '',
|
||||
page: {},
|
||||
filters: {},
|
||||
types: ['*'],
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('SearchContext', () => {
|
||||
|
||||
const initialState = {
|
||||
term: '',
|
||||
pageCursor: '',
|
||||
page: {},
|
||||
filters: {},
|
||||
types: ['*'],
|
||||
};
|
||||
@@ -93,22 +93,26 @@ describe('SearchContext', () => {
|
||||
initialProps: {
|
||||
initialState: {
|
||||
...initialState,
|
||||
pageCursor: '1',
|
||||
page: { offset: 0, limit: 25 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.pageCursor).toBe('1');
|
||||
expect(result.current.page).toEqual({ offset: 0, limit: 25 });
|
||||
|
||||
act(() => {
|
||||
result.current.setTerm('first term');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPage({ offset: 75, limit: 25 });
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.pageCursor).toBe('1');
|
||||
expect(result.current.page).toEqual({ offset: 75, limit: 25 });
|
||||
|
||||
act(() => {
|
||||
result.current.setTerm('second term');
|
||||
@@ -116,7 +120,7 @@ describe('SearchContext', () => {
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.pageCursor).toBe('');
|
||||
expect(result.current.page).toEqual({ offset: 0, limit: 25 });
|
||||
});
|
||||
|
||||
describe('Performs search (and sets results)', () => {
|
||||
@@ -139,7 +143,10 @@ describe('SearchContext', () => {
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
filters: {},
|
||||
types: ['*'],
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
term,
|
||||
});
|
||||
});
|
||||
@@ -163,12 +170,15 @@ describe('SearchContext', () => {
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
filters,
|
||||
types: ['*'],
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
term: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('When pageCursor is set', async () => {
|
||||
it('When page is set', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
|
||||
wrapper,
|
||||
initialProps: {
|
||||
@@ -178,17 +188,20 @@ describe('SearchContext', () => {
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const pageCursor = 'pageCursor';
|
||||
const page = { offset: 25, limit: 50 };
|
||||
|
||||
act(() => {
|
||||
result.current.setPageCursor(pageCursor);
|
||||
result.current.setPage(page);
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
pageCursor,
|
||||
filters: {},
|
||||
types: ['*'],
|
||||
limit: 50,
|
||||
offset: 25,
|
||||
term: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,8 +224,11 @@ describe('SearchContext', () => {
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith({
|
||||
...initialState,
|
||||
types,
|
||||
filters: {},
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
term: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,19 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { useAsync, usePrevious } from 'react-use';
|
||||
import { SearchResultSet } from '@backstage/search-common';
|
||||
import { searchApiRef } from '../../apis';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { SearchResultSet } from '@backstage/search-common';
|
||||
import React, {
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAsync, usePrevious } from 'react-use';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { searchApiRef } from '../../apis';
|
||||
|
||||
type Page = { limit?: number; offset?: number };
|
||||
|
||||
type SearchContextValue = {
|
||||
result: AsyncState<SearchResultSet>;
|
||||
@@ -36,13 +38,13 @@ type SearchContextValue = {
|
||||
setTypes: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
filters: JsonObject;
|
||||
setFilters: React.Dispatch<React.SetStateAction<JsonObject>>;
|
||||
pageCursor: string;
|
||||
setPageCursor: React.Dispatch<React.SetStateAction<string>>;
|
||||
page: Page;
|
||||
setPage: React.Dispatch<React.SetStateAction<Page>>;
|
||||
};
|
||||
|
||||
type SettableSearchContext = Omit<
|
||||
SearchContextValue,
|
||||
'result' | 'setTerm' | 'setTypes' | 'setFilters' | 'setPageCursor'
|
||||
'result' | 'setTerm' | 'setTypes' | 'setFilters' | 'setPage'
|
||||
>;
|
||||
|
||||
export const SearchContext = createContext<SearchContextValue | undefined>(
|
||||
@@ -52,14 +54,14 @@ export const SearchContext = createContext<SearchContextValue | undefined>(
|
||||
export const SearchContextProvider = ({
|
||||
initialState = {
|
||||
term: '',
|
||||
pageCursor: '',
|
||||
page: {},
|
||||
filters: {},
|
||||
types: [],
|
||||
},
|
||||
children,
|
||||
}: PropsWithChildren<{ initialState?: SettableSearchContext }>) => {
|
||||
const searchApi = useApi(searchApiRef);
|
||||
const [pageCursor, setPageCursor] = useState<string>(initialState.pageCursor);
|
||||
const [page, setPage] = useState<Page>(initialState.page);
|
||||
const [filters, setFilters] = useState<JsonObject>(initialState.filters);
|
||||
const [term, setTerm] = useState<string>(initialState.term);
|
||||
const [types, setTypes] = useState<string[]>(initialState.types);
|
||||
@@ -70,18 +72,19 @@ export const SearchContextProvider = ({
|
||||
searchApi.query({
|
||||
term,
|
||||
filters,
|
||||
pageCursor,
|
||||
offset: page?.offset,
|
||||
limit: page?.limit,
|
||||
types,
|
||||
}),
|
||||
[term, filters, types, pageCursor],
|
||||
[term, filters, types, page],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Any time a term is reset, we want to start from page 0.
|
||||
if (term && prevTerm && term !== prevTerm) {
|
||||
setPageCursor('');
|
||||
setPage(initialState.page);
|
||||
}
|
||||
}, [term, prevTerm]);
|
||||
}, [term, prevTerm, initialState.page]);
|
||||
|
||||
const value: SearchContextValue = {
|
||||
result,
|
||||
@@ -91,8 +94,8 @@ export const SearchContextProvider = ({
|
||||
setTerm,
|
||||
types,
|
||||
setTypes,
|
||||
pageCursor,
|
||||
setPageCursor,
|
||||
page,
|
||||
setPage,
|
||||
};
|
||||
|
||||
return <SearchContext.Provider value={value} children={children} />;
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('SearchFilter', () => {
|
||||
term: '',
|
||||
filters: {},
|
||||
types: [],
|
||||
pageCursor: '',
|
||||
page: {},
|
||||
};
|
||||
|
||||
const name = 'field';
|
||||
|
||||
@@ -74,21 +74,25 @@ describe('SearchPage', () => {
|
||||
const expectedTerm = 'justin bieber';
|
||||
const expectedTypes = ['software-catalog'];
|
||||
const expectedFilters = { [expectedFilterField]: expectedFilterValue };
|
||||
const expectedPageCursor = 'page2-or-something';
|
||||
const expectedOffset = 25;
|
||||
const expectedLimit = 50;
|
||||
|
||||
// e.g. ?query=petstore&pageCursor=1&filters[lifecycle][]=experimental&filters[kind]=Component
|
||||
// e.g. ?query=petstore&offset=25&limit=50&filters[lifecycle][]=experimental&filters[kind]=Component
|
||||
(useLocation as jest.Mock).mockReturnValueOnce({
|
||||
search: `?query=${expectedTerm}&types[]=${expectedTypes[0]}&filters[${expectedFilterField}]=${expectedFilterValue}&pageCursor=${expectedPageCursor}`,
|
||||
search: `?query=${expectedTerm}&types[]=${expectedTypes[0]}&filters[${expectedFilterField}]=${expectedFilterValue}&offset=${expectedOffset}&limit=${expectedLimit}`,
|
||||
});
|
||||
|
||||
// When we render the page...
|
||||
await renderInTestApp(<SearchPage />);
|
||||
|
||||
// Then search context should be set with these values...
|
||||
expect(setTermMock).toHaveBeenCalledWith(expectedTerm);
|
||||
expect(setTypesMock).toHaveBeenCalledWith(expectedTypes);
|
||||
expect(setPageCursorMock).toHaveBeenCalledWith(expectedPageCursor);
|
||||
expect(setFiltersMock).toHaveBeenCalledWith(expectedFilters);
|
||||
// Then search context should be initialized with these values...
|
||||
const calls = (SearchContextProvider as jest.Mock).mock.calls[0];
|
||||
const actualInitialState = calls[0].initialState;
|
||||
expect(actualInitialState.term).toEqual(expectedTerm);
|
||||
expect(actualInitialState.types).toEqual(expectedTypes);
|
||||
expect(actualInitialState.page.limit).toEqual(expectedLimit);
|
||||
expect(actualInitialState.page.offset).toEqual(expectedOffset);
|
||||
expect(actualInitialState.filters).toStrictEqual(expectedFilters);
|
||||
});
|
||||
|
||||
it('renders provided router element', async () => {
|
||||
@@ -108,7 +112,7 @@ describe('SearchPage', () => {
|
||||
(useSearch as jest.Mock).mockReturnValueOnce({
|
||||
term: 'bieber',
|
||||
types: ['software-catalog'],
|
||||
pageCursor: 'page2-or-something',
|
||||
page: { offset: 25, limit: 50 },
|
||||
filters: { anyKey: 'anyValue' },
|
||||
setTerm: setTermMock,
|
||||
setTypes: setTypesMock,
|
||||
@@ -116,7 +120,7 @@ describe('SearchPage', () => {
|
||||
setPageCursor: setPageCursorMock,
|
||||
});
|
||||
const expectedLocation = encodeURI(
|
||||
'?query=bieber&types[]=software-catalog&pageCursor=page2-or-something&filters[anyKey]=anyValue',
|
||||
'?query=bieber&types[]=software-catalog&offset=25&limit=50&filters[anyKey]=anyValue',
|
||||
);
|
||||
|
||||
await renderInTestApp(<SearchPage />);
|
||||
|
||||
@@ -24,6 +24,7 @@ jest.mock('../SearchContext', () => ({
|
||||
...jest.requireActual('../SearchContext'),
|
||||
useSearch: jest.fn().mockReturnValue({
|
||||
result: {},
|
||||
page: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -93,16 +94,73 @@ describe('SearchResult', () => {
|
||||
|
||||
it('Calls children with results set to result.value', async () => {
|
||||
(useSearch as jest.Mock).mockReturnValueOnce({
|
||||
result: { loading: false, error: '', value: { results: [] } },
|
||||
result: {
|
||||
loading: false,
|
||||
error: '',
|
||||
value: {
|
||||
totalCount: 1,
|
||||
results: [
|
||||
{
|
||||
type: 'some-type',
|
||||
document: {
|
||||
title: 'some-title',
|
||||
text: 'some-text',
|
||||
location: 'some-location',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
page: {},
|
||||
});
|
||||
|
||||
await renderInTestApp(
|
||||
const { getByText } = await renderInTestApp(
|
||||
<SearchResult>
|
||||
{({ results }) => {
|
||||
expect(results).toEqual([]);
|
||||
return <></>;
|
||||
return <>Results {results.length}</>;
|
||||
}}
|
||||
</SearchResult>,
|
||||
);
|
||||
|
||||
expect(getByText('Results 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Starts on initial page if no offset is set', async () => {
|
||||
(useSearch as jest.Mock).mockReturnValueOnce({
|
||||
page: {},
|
||||
result: {
|
||||
loading: false,
|
||||
error: '',
|
||||
value: { results: [{}], totalCount: 100 },
|
||||
},
|
||||
});
|
||||
|
||||
const { getByLabelText } = await renderInTestApp(
|
||||
<SearchResult>{({}) => <></>}</SearchResult>,
|
||||
);
|
||||
|
||||
expect(getByLabelText('page 1')).toHaveAttribute('aria-current', 'true');
|
||||
expect(getByLabelText('Go to page 2')).toBeInTheDocument();
|
||||
expect(getByLabelText('Go to page 3')).toBeInTheDocument();
|
||||
expect(getByLabelText('Go to page 4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows the right page', async () => {
|
||||
(useSearch as jest.Mock).mockReturnValueOnce({
|
||||
page: { offset: 25 },
|
||||
result: {
|
||||
loading: false,
|
||||
error: '',
|
||||
value: { results: [{}], totalCount: 63 },
|
||||
},
|
||||
});
|
||||
|
||||
const { getByLabelText } = await renderInTestApp(
|
||||
<SearchResult>{({}) => <></>}</SearchResult>,
|
||||
);
|
||||
|
||||
expect(getByLabelText('Go to page 1')).toBeInTheDocument();
|
||||
expect(getByLabelText('page 2')).toHaveAttribute('aria-current', 'true');
|
||||
expect(getByLabelText('Go to page 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,16 +20,20 @@ import {
|
||||
ResponseErrorPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { SearchResult } from '@backstage/search-common';
|
||||
import { Pagination } from '@material-ui/lab';
|
||||
import React from 'react';
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
type Props = {
|
||||
children: (results: { results: SearchResult[] }) => JSX.Element;
|
||||
initialPageSize?: number;
|
||||
};
|
||||
|
||||
const SearchResultComponent = ({ children }: Props) => {
|
||||
const SearchResultComponent = ({ children, initialPageSize = 25 }: Props) => {
|
||||
const {
|
||||
result: { loading, error, value },
|
||||
page,
|
||||
setPage,
|
||||
} = useSearch();
|
||||
|
||||
if (loading) {
|
||||
@@ -48,7 +52,25 @@ const SearchResultComponent = ({ children }: Props) => {
|
||||
return <EmptyState missing="data" title="Sorry, no results were found" />;
|
||||
}
|
||||
|
||||
return children({ results: value.results });
|
||||
const pageSize = page.limit ?? initialPageSize;
|
||||
const totalPages = Math.ceil(value.totalCount / pageSize);
|
||||
const currentPage = page.offset ? Math.floor(page.offset / pageSize) + 1 : 1;
|
||||
|
||||
const handlePageChange = (_: React.ChangeEvent<unknown>, pageNum: number) => {
|
||||
setPage({ offset: (pageNum - 1) * pageSize, limit: pageSize });
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ results: value.results })}
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { SearchResultComponent as SearchResult };
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('SearchType', () => {
|
||||
term: '',
|
||||
filters: {},
|
||||
types: [],
|
||||
pageCursor: '',
|
||||
page: {},
|
||||
};
|
||||
|
||||
const name = 'field';
|
||||
|
||||
Reference in New Issue
Block a user