Merge pull request #13968 from backstage/bux/search-per-page-limit-component

[Search] Create a per-page limiter component
This commit is contained in:
Camila Belo
2022-10-14 12:15:35 +02:00
committed by GitHub
10 changed files with 613 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
---
'@backstage/plugin-search-react': minor
---
A `<SearchPagination />` component was created for limiting the number of results shown per search page. Use this new component to give users options to select how many search results they want to display per page. The default options are 10, 25, 50, 100.
See examples below:
_Basic_
```jsx
import React, { useState } from 'react';
import { Grid } from '@material-ui/core';
import { Page, Header, Content, Lifecycle } from '@backstage/core-components';
import {
SearchBarBase,
SearchPaginationBase,
SearchResultList,
} from '@backstage/plugin-search-react';
const SearchPage = () => {
const [term, setTerm] = useState('');
const [pageLimit, setPageLimit] = useState(25);
const [pageCursor, setPageCursor] = useState<string>();
return (
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBarBase value={term} onChange={setTerm} />
</Grid>
<Grid item xs={12}>
<SearchPaginationBase
limit={pageLimit}
onLimitChange={setPageLimit}
cursor={pageCursor}
onCursorChange={setPageCursor}
/>
</Grid>
<Grid item xs={12}>
<SearchResultList query={{ term, pageLimit }} />
</Grid>
</Grid>
</Content>
</Page>
);
};
```
_With context_
```jsx
import React from 'react';
import { Grid } from '@material-ui/core';
import { Page, Header, Content, Lifecycle } from '@backstage/core-components';
import {
SearchBar,
SearchResult,
SearchPagination,
SearchResultListLayout,
SearchContextProvider,
DefaultResultListItem,
} from '@backstage/plugin-search-react';
const SearchPage = () => (
<SearchContextProvider>
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBar />
</Grid>
<Grid item xs={12}>
<SearchPagination />
</Grid>
<Grid item xs={12}>
<SearchResult>
{({ results }) => (
<SearchResultListLayout
resultItems={results}
renderResultItem={({ document }) => (
<DefaultResultListItem
key={document.location}
result={document}
/>
)}
/>
)}
</SearchResult>
</Grid>
</Grid>
</Content>
</Page>
</SearchContextProvider>
);
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Add the new search pagination component to the search page template.
@@ -35,6 +35,7 @@ import {
SearchBar,
SearchFilter,
SearchResult,
SearchPagination,
SearchResultPager,
useSearch,
} from '@backstage/plugin-search-react';
@@ -129,6 +130,7 @@ const SearchPage = () => {
</Grid>
)}
<Grid item xs>
<SearchPagination />
<SearchResult>
{({ results }) => (
<List>
@@ -14,6 +14,7 @@ import {
SearchBar,
SearchFilter,
SearchResult,
SearchPagination,
useSearch,
} from '@backstage/plugin-search-react';
import {
@@ -109,6 +110,7 @@ const SearchPage = () => {
</Paper>
</Grid>
<Grid item xs={9}>
<SearchPagination />
<SearchResult>
{({ results }) => (
<List>
+46
View File
@@ -211,6 +211,52 @@ export type SearchFilterWrapperProps = SearchFilterComponentProps & {
debug?: boolean;
};
// @public
export const SearchPagination: (props: SearchPaginationProps) => JSX.Element;
// @public
export const SearchPaginationBase: (
props: SearchPaginationBaseProps,
) => JSX.Element;
// @public
export type SearchPaginationBaseProps = {
className?: string;
total?: number;
cursor?: string;
onCursorChange?: (pageCursor: string) => void;
limit?: number;
limitLabel?: ReactNode;
limitText?: SearchPaginationLimitText;
limitOptions?: SearchPaginationLimitOption[];
onLimitChange?: (value: number) => void;
};
// @public
export type SearchPaginationLimitOption<
Current extends number = 101,
Accumulator extends number[] = [],
> = Accumulator['length'] extends Current
? Accumulator[number]
: SearchPaginationLimitOption<
Current,
[...Accumulator, Accumulator['length']]
>;
// @public
export type SearchPaginationLimitText = (params: {
from: number;
to: number;
page: number;
count: number;
}) => ReactNode;
// @public
export type SearchPaginationProps = Omit<
SearchPaginationBaseProps,
'pageLimit' | 'onPageLimitChange' | 'pageCursor' | 'onPageCursorChange'
>;
// @public
export const SearchResult: (props: SearchResultProps) => JSX.Element;
@@ -0,0 +1,63 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType } from 'react';
import { Grid } from '@material-ui/core';
import { TestApiProvider } from '@backstage/test-utils';
import { searchApiRef, MockSearchApi } from '../../api';
import { SearchContextProvider } from '../../context';
import { SearchPagination } from './SearchPagination';
export default {
title: 'Plugins/Search/SearchPagination',
component: SearchPagination,
decorators: [
(Story: ComponentType<{}>) => (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
<SearchContextProvider>
<Grid container direction="row">
<Grid item xs={12}>
<Story />
</Grid>
</Grid>
</SearchContextProvider>
</TestApiProvider>
),
],
};
export const Default = () => {
return <SearchPagination />;
};
export const CustomPageLimitLabel = () => {
return <SearchPagination limitLabel="Rows per page:" />;
};
export const CustomPageLimitText = () => {
return (
<SearchPagination
limitText={({ from, to }) => `${from}-${to} of more than ${to}`}
/>
);
};
export const CustomPageLimitOptions = () => {
return <SearchPagination limitOptions={[5, 10, 20]} />;
};
@@ -0,0 +1,188 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithEffects, TestApiProvider } from '@backstage/test-utils';
import { searchApiRef } from '../../api';
import { SearchContextProvider } from '../../context';
import { SearchPagination } from './SearchPagination';
const query = jest.fn().mockResolvedValue({
results: [],
nextPageCursor: 'Mg==',
previousPageCursor: 'MA==',
});
describe('SearchPagination', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Renders without exploding', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText('Results per page:')).toBeInTheDocument();
expect(screen.getByText('25')).toBeInTheDocument();
expect(screen.getByText('1-25')).toBeInTheDocument();
expect(screen.getByLabelText('Next page')).toBeEnabled();
expect(screen.getByLabelText('Previous page')).toBeDisabled();
});
it('Define default page limit options', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination />
</SearchContextProvider>
</TestApiProvider>,
);
await userEvent.click(screen.getByText('25'));
const options = screen.getAllByRole('option');
expect(options).toHaveLength(4);
expect(options[0]).toHaveTextContent('10');
expect(options[1]).toHaveTextContent('25');
expect(options[2]).toHaveTextContent('50');
expect(options[3]).toHaveTextContent('100');
});
it('Accept custom page limit label', async () => {
const label = 'Page limit:';
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination limitLabel={label} />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText(label)).toBeInTheDocument();
});
it('Show the total in text', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination total={100} />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText('of 100')).toBeInTheDocument();
});
it('Accept custom page limit text', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination
limitText={({ from, to }) => `${from}-${to} of more than ${to}`}
/>
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText('1-25 of more than 25')).toBeInTheDocument();
});
it('Accept custom page limit options', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination limitOptions={[5, 10, 20, 25]} />
</SearchContextProvider>
</TestApiProvider>,
);
await userEvent.click(screen.getByText('25'));
const options = screen.getAllByRole('option');
expect(options).toHaveLength(4);
expect(options[0]).toHaveTextContent('5');
expect(options[1]).toHaveTextContent('10');
expect(options[2]).toHaveTextContent('20');
expect(options[3]).toHaveTextContent('25');
});
it('Set page limit in the context', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination />
</SearchContextProvider>
</TestApiProvider>,
);
await userEvent.click(screen.getByText('25'));
await userEvent.click(screen.getByText('10'));
expect(query).toHaveBeenCalledWith(
expect.objectContaining({
pageLimit: 10,
}),
);
});
it('Set page cursor in the context', async () => {
const initialState = {
term: '',
types: [],
filters: {},
pageCursor: 'MQ==', // page: 1
};
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider initialState={initialState}>
<SearchPagination />
</SearchContextProvider>
</TestApiProvider>,
);
await userEvent.click(screen.getByLabelText('Next page'));
expect(screen.getByText('51-75')).toBeInTheDocument();
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
pageCursor: 'Mg==', // page: 2
}),
);
await userEvent.click(screen.getByLabelText('Previous page'));
expect(screen.getByText('26-50')).toBeInTheDocument();
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
pageCursor: 'MQ==', // page: 1
}),
);
});
});
@@ -0,0 +1,190 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
ReactNode,
ChangeEvent,
MouseEvent,
useCallback,
useMemo,
} from 'react';
import { TablePagination } from '@material-ui/core';
import { useSearch } from '../../context';
const encodePageCursor = (pageCursor: number): string => {
return Buffer.from(pageCursor.toString(), 'utf-8').toString('base64');
};
const decodePageCursor = (pageCursor?: string): number => {
if (!pageCursor) return 0;
return Number(Buffer.from(pageCursor, 'base64').toString('utf-8'));
};
/**
* A page limit option, this value must not be greater than 100.
* @public
*/
export type SearchPaginationLimitOption<
Current extends number = 101,
Accumulator extends number[] = [],
> = Accumulator['length'] extends Current
? Accumulator[number]
: SearchPaginationLimitOption<
Current,
[...Accumulator, Accumulator['length']]
>;
/**
* A page limit text, this function is called with a "\{ from, to, page, count \}" object.
* @public
*/
export type SearchPaginationLimitText = (params: {
from: number;
to: number;
page: number;
count: number;
}) => ReactNode;
/**
* Props for {@link SearchPaginationBase}.
* @public
*/
export type SearchPaginationBaseProps = {
/**
* The component class name.
*/
className?: string;
/**
* The total number of results.
* For an unknown number of items, provide -1.
* Defaults to -1.
*/
total?: number;
/**
* The cursor for the current page.
*/
cursor?: string;
/**
* Callback fired when the current page cursor is changed.
*/
onCursorChange?: (pageCursor: string) => void;
/**
* The limit of results per page.
* Set -1 to display all the results.
*/
limit?: number;
/**
* Customize the results per page label.
* Defaults to "Results per page:".
*/
limitLabel?: ReactNode;
/**
* Customize the results per page text.
* Defaults to "(\{ from, to, count \}) =\> count \> 0 ? `of $\{count\}` : `$\{from\}-$\{to\}`".
*/
limitText?: SearchPaginationLimitText;
/**
* Options for setting how many results show per page.
* If less than two options are available, no select field will be displayed.
* Use -1 for the value with a custom label to show all the results.
* Defaults to [10, 25, 50, 100].
*/
limitOptions?: SearchPaginationLimitOption[];
/**
* Callback fired when the number of results per page is changed.
*/
onLimitChange?: (value: number) => void;
};
/**
* A component with controls for search results pagination.
* @param props - See {@link SearchPaginationBaseProps}.
* @public
*/
export const SearchPaginationBase = (props: SearchPaginationBaseProps) => {
const {
total: count = -1,
cursor: pageCursor,
onCursorChange: onPageCursorChange,
limit: rowsPerPage = 25,
limitLabel: labelRowsPerPage = 'Results per page:',
limitText: labelDisplayedRows = ({ from, to }) =>
count > 0 ? `of ${count}` : `${from}-${to}`,
limitOptions: rowsPerPageOptions,
onLimitChange: onPageLimitChange,
...rest
} = props;
const page = useMemo(() => decodePageCursor(pageCursor), [pageCursor]);
const handlePageChange = useCallback(
(_: MouseEvent<HTMLButtonElement> | null, newValue: number) => {
onPageCursorChange?.(encodePageCursor(newValue));
},
[onPageCursorChange],
);
const handleRowsPerPageChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
const newValue = e.target.value;
onPageLimitChange?.(parseInt(newValue, 10));
},
[onPageLimitChange],
);
return (
<TablePagination
{...rest}
component="div"
count={count}
page={page}
onPageChange={handlePageChange}
rowsPerPage={rowsPerPage}
labelRowsPerPage={labelRowsPerPage}
labelDisplayedRows={labelDisplayedRows}
rowsPerPageOptions={rowsPerPageOptions}
onRowsPerPageChange={handleRowsPerPageChange}
/>
);
};
/**
* Props for {@link SearchPagination}.
* @public
*/
export type SearchPaginationProps = Omit<
SearchPaginationBaseProps,
'pageLimit' | 'onPageLimitChange' | 'pageCursor' | 'onPageCursorChange'
>;
/**
* A component for setting the search context page limit and cursor.
* @param props - See {@link SearchPaginationProps}.
* @public
*/
export const SearchPagination = (props: SearchPaginationProps) => {
const { pageLimit, setPageLimit, pageCursor, setPageCursor } = useSearch();
return (
<SearchPaginationBase
{...props}
limit={pageLimit}
onLimitChange={setPageLimit}
cursor={pageCursor}
onCursorChange={setPageCursor}
/>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './SearchPagination';
@@ -20,6 +20,7 @@ export * from './SearchAutocomplete';
export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchPagination';
export * from './SearchResultList';
export * from './SearchResultGroup';
export * from './DefaultResultListItem';