Implement offset and limit based paging in frontend

Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
Oliver Sand
2021-08-16 11:17:32 +02:00
parent 487b84b4a0
commit 6876c48423
10 changed files with 193 additions and 117 deletions
+3 -3
View File
@@ -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';