diff --git a/.changeset/search-days-pull.md b/.changeset/search-days-pull.md index 72fb9974a4..03000e4870 100644 --- a/.changeset/search-days-pull.md +++ b/.changeset/search-days-pull.md @@ -2,9 +2,7 @@ '@backstage/plugin-search-react': minor --- -A `` component was created for limiting the number of results shown per search page. -Use this new component to give users a combination of options to define how many search results they want to display per page. -The default options are 10, 25, 50, 100. +A `` 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: @@ -16,13 +14,14 @@ import { Grid } from '@material-ui/core'; import { Page, Header, Content, Lifecycle } from '@backstage/core-components'; import { SearchBarBase, - SearchResultLimiterBase, + SearchPaginationBase, SearchResultList, } from '@backstage/plugin-search-react'; const SearchPage = () => { const [term, setTerm] = useState(''); const [pageLimit, setPageLimit] = useState(25); + const [pageCursor, setPageCursor] = useState(); return ( @@ -33,9 +32,11 @@ const SearchPage = () => { - @@ -57,7 +58,7 @@ import { Page, Header, Content, Lifecycle } from '@backstage/core-components'; import { SearchBar, SearchResult, - SearchResultLimiter, + SearchPagination, SearchResultListLayout, SearchContextProvider, DefaultResultListItem, @@ -73,7 +74,7 @@ const SearchPage = () => ( - + diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index b97ac99558..d324b683d8 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -35,7 +35,7 @@ import { SearchBar, SearchFilter, SearchResult, - SearchResultLimiter, + SearchPagination, SearchResultPager, useSearch, } from '@backstage/plugin-search-react'; @@ -56,10 +56,6 @@ const useStyles = makeStyles((theme: Theme) => ({ padding: theme.spacing(2), marginTop: theme.spacing(2), }, - limiter: { - width: '100%', - justifyContent: 'flex-end', - }, })); const SearchPage = () => { @@ -134,7 +130,7 @@ const SearchPage = () => { )} - + {({ results }) => ( diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index 24b70240a0..57c1791d9a 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -211,6 +211,50 @@ 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; + pageCursor?: string; + onPageCursorChange?: (pageCursor: string) => void; + pageLimit?: number; + pageLimitLabel?: ReactNode; + pageLimitText?: SearchPaginationLimitText; + pageLimitOptions?: SearchPaginationLimitOption[]; + onPageLimitChange?: (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; +}) => ReactNode; + +// @public +export type SearchPaginationProps = Omit< + SearchPaginationBaseProps, + 'pageLimit' | 'onPageLimitChange' | 'pageCursor' | 'onPageCursorChange' +>; + // @public export const SearchResult: (props: SearchResultProps) => JSX.Element; @@ -320,40 +364,6 @@ export const SearchResultGroupTextFilterField: ( export type SearchResultGroupTextFilterFieldProps = SearchResultGroupFilterFieldPropsWith<{}>; -// @public -export const SearchResultLimiter: ( - props: SearchResultLimiterProps, -) => JSX.Element; - -// @public -export const SearchResultLimiterBase: ( - props: SearchResultLimiterBaseProps, -) => JSX.Element; - -// @public -export type SearchResultLimiterBaseProps = { - id?: string; - className?: string; - label?: ReactNode; - options?: SearchResultLimiterOption[]; - value?: number; - onChange?: (value: number) => void; -}; - -// @public -export type SearchResultLimiterOption< - Current extends number = 101, - Accumulator extends number[] = [], -> = Accumulator['length'] extends Current - ? Accumulator[number] - : SearchResultLimiterOption; - -// @public -export type SearchResultLimiterProps = Omit< - SearchResultLimiterBaseProps, - 'value' | 'onChange' ->; - // @public export const SearchResultList: (props: SearchResultListProps) => JSX.Element; diff --git a/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.stories.tsx b/plugins/search-react/src/components/SearchPagination/SearchPagination.stories.tsx similarity index 72% rename from plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.stories.tsx rename to plugins/search-react/src/components/SearchPagination/SearchPagination.stories.tsx index f098f1ab68..eca668d165 100644 --- a/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.stories.tsx +++ b/plugins/search-react/src/components/SearchPagination/SearchPagination.stories.tsx @@ -22,11 +22,11 @@ import { TestApiProvider } from '@backstage/test-utils'; import { searchApiRef, MockSearchApi } from '../../api'; import { SearchContextProvider } from '../../context'; -import { SearchResultLimiter } from './SearchResultLimiter'; +import { SearchPagination } from './SearchPagination'; export default { - title: 'Plugins/Search/SearchResultLimiter', - component: SearchResultLimiter, + title: 'Plugins/Search/SearchPagination', + component: SearchPagination, decorators: [ (Story: ComponentType<{}>) => ( @@ -43,13 +43,17 @@ export default { }; export const Default = () => { - return ; + return ; }; -export const CustomLabel = () => { - return ; +export const CustomPageLimitLabel = () => { + return ; }; -export const CustomOptions = () => { - return ; +export const CustomPageLimitText = () => { + return `${from}-${to}`} />; +}; + +export const CustomPageLimitOptions = () => { + return ; }; diff --git a/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.test.tsx b/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx similarity index 58% rename from plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.test.tsx rename to plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx index a6f9f5b1e9..723a8a0031 100644 --- a/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.test.tsx +++ b/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx @@ -23,11 +23,15 @@ import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; import { searchApiRef } from '../../api'; import { SearchContextProvider } from '../../context'; -import { SearchResultLimiter } from './SearchResultLimiter'; +import { SearchPagination } from './SearchPagination'; -const query = jest.fn().mockResolvedValue({ results: [] }); +const query = jest.fn().mockResolvedValue({ + results: [], + nextPageCursor: 'Mg==', + previousPageCursor: 'MA==', +}); -describe('SearchResultLimiter', () => { +describe('SearchPagination', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -36,20 +40,23 @@ describe('SearchResultLimiter', () => { await renderWithEffects( - + , ); expect(screen.getByText('Results per page:')).toBeInTheDocument(); expect(screen.getByText('25')).toBeInTheDocument(); + expect(screen.getByText('1-25 of more than 25')).toBeInTheDocument(); + expect(screen.getByLabelText('Next page')).toBeEnabled(); + expect(screen.getByLabelText('Previous page')).toBeDisabled(); }); - it('Define default options', async () => { + it('Define default page limit options', async () => { await renderWithEffects( - + , ); @@ -64,11 +71,55 @@ describe('SearchResultLimiter', () => { expect(options[3]).toHaveTextContent('100'); }); + it('Accept custom page limit label', async () => { + const label = 'Page limit:'; + await renderWithEffects( + + + + + , + ); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it('Accept custom page limit text', async () => { + await renderWithEffects( + + + `${from}-${to}`} /> + + , + ); + + expect(screen.getByText('1-25')).toBeInTheDocument(); + }); + + it('Accept custom page limit options', async () => { + await renderWithEffects( + + + + + , + ); + + 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( - + , ); @@ -84,35 +135,40 @@ describe('SearchResultLimiter', () => { ); }); - it('Accept custom label', async () => { - const label = 'Custom label'; + it('Set page cursor in the context', async () => { + const initialState = { + term: '', + types: [], + filters: {}, + pageCursor: 'MQ==', // page: 1 + }; + await renderWithEffects( - - + + , ); - expect(screen.getByText(label)).toBeInTheDocument(); - }); + await userEvent.click(screen.getByLabelText('Next page')); - it('Accept custom options', async () => { - await renderWithEffects( - - - - - , + expect(screen.getByText('51-75 of more than 75')).toBeInTheDocument(); + + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + pageCursor: 'Mg==', // page: 2 + }), ); - await userEvent.click(screen.getByText('25')); + await userEvent.click(screen.getByLabelText('Previous page')); - 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'); + expect(screen.getByText('26-50 of more than 50')).toBeInTheDocument(); + + expect(query).toHaveBeenLastCalledWith( + expect.objectContaining({ + pageCursor: 'MQ==', // page: 1 + }), + ); }); }); diff --git a/plugins/search-react/src/components/SearchPagination/SearchPagination.tsx b/plugins/search-react/src/components/SearchPagination/SearchPagination.tsx new file mode 100644 index 0000000000..c0d1c127c8 --- /dev/null +++ b/plugins/search-react/src/components/SearchPagination/SearchPagination.tsx @@ -0,0 +1,178 @@ +/* + * 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 \}" object. + * @public + */ +export type SearchPaginationLimitText = (params: { + from: number; + to: number; + page: number; +}) => ReactNode; + +/** + * Props for {@link SearchPaginationBase}. + * @public + */ +export type SearchPaginationBaseProps = { + /** + * The component class name. + */ + className?: string; + /** + * The cursor for the current page. + */ + pageCursor?: string; + /** + * Callback fired when the current page cursor is changed. + */ + onPageCursorChange?: (pageCursor: string) => void; + /** + * The limit of results per page. + * Set -1 to display all the results. + */ + pageLimit?: number; + /** + * Customize the results per page label. + */ + pageLimitLabel?: ReactNode; + /** + * Customize the results per page text. + */ + pageLimitText?: 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. + */ + pageLimitOptions?: SearchPaginationLimitOption[]; + /** + * Callback fired when the number of results per page is changed. + */ + onPageLimitChange?: (value: number) => void; +}; + +/** + * A component with controls for search results pagination. + * @param props - See {@link SearchPaginationBaseProps}. + * @public + */ +export const SearchPaginationBase = (props: SearchPaginationBaseProps) => { + const { + pageCursor, + onPageCursorChange, + pageLimit: rowsPerPage = 25, + pageLimitLabel: labelRowsPerPage = 'Results per page:', + pageLimitText: labelDisplayedRows, + pageLimitOptions: rowsPerPageOptions, + onPageLimitChange, + ...rest + } = props; + + const page = useMemo(() => decodePageCursor(pageCursor), [pageCursor]); + + const handlePageChange = useCallback( + (_: MouseEvent | null, newValue: number) => { + onPageCursorChange?.(encodePageCursor(newValue)); + }, + [onPageCursorChange], + ); + + const handleRowsPerPageChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value; + onPageLimitChange?.(parseInt(newValue, 10)); + }, + [onPageLimitChange], + ); + + return ( + + ); +}; + +/** + * 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 ( + + ); +}; diff --git a/plugins/search-react/src/components/SearchResultLimiter/index.ts b/plugins/search-react/src/components/SearchPagination/index.ts similarity index 93% rename from plugins/search-react/src/components/SearchResultLimiter/index.ts rename to plugins/search-react/src/components/SearchPagination/index.ts index 411e48b5f5..74053f0e11 100644 --- a/plugins/search-react/src/components/SearchResultLimiter/index.ts +++ b/plugins/search-react/src/components/SearchPagination/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './SearchResultLimiter'; +export * from './SearchPagination'; diff --git a/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.tsx b/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.tsx deleted file mode 100644 index 7f2d1ffc58..0000000000 --- a/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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, useCallback } from 'react'; -import { - Box, - InputBase, - MenuItem, - Select, - Typography, - useTheme, -} from '@material-ui/core'; -import { useSearch } from '../../context'; - -/** - * A page limit option, this value must not be greater than 100. - * @public - */ -export type SearchResultLimiterOption< - Current extends number = 101, - Accumulator extends number[] = [], -> = Accumulator['length'] extends Current - ? Accumulator[number] - : SearchResultLimiterOption; - -/** - * Props for {@link SearchResultLimiterBase}. - * @public - */ -export type SearchResultLimiterBaseProps = { - id?: string; - className?: string; - /** - * A label for the combobox. - */ - label?: ReactNode; - /** - * The combobox labels, defaults to 10, 25, 50 and 100. - */ - options?: SearchResultLimiterOption[]; - /** - * Combobox selected option, defaults to 25; - */ - value?: number; - /** - * The callback handler called when the selected option changed. - */ - onChange?: (value: number) => void; -}; - -const DEFAULT_PAGE_LIMIT = 25; - -/** - * A component for selecting the number of results per page. - * @param props - See {@link SearchResultLimiterBaseProps}. - * @public - */ -export const SearchResultLimiterBase = ( - props: SearchResultLimiterBaseProps, -) => { - const { - id = 'search-result-limiter', - className, - label = 'Results per page:', - options = [10, 50, 100], - value = DEFAULT_PAGE_LIMIT, - onChange = () => {}, - } = props; - - const theme = useTheme(); - - const handleChange = useCallback( - (e: ChangeEvent<{ value: unknown }>) => { - const newValue = e.target.value; - if (typeof newValue === 'number') { - onChange(newValue); - } - }, - [onChange], - ); - - return ( - - - {label} - - - - ); -}; - -/** - * Props for {@link SearchResultLimiter}. - * @public - */ -export type SearchResultLimiterProps = Omit< - SearchResultLimiterBaseProps, - 'value' | 'onChange' ->; - -/** - * A component for setting the search context page limit. - * @param props - See {@link SearchResultLimiterProps}. - * @public - */ -export const SearchResultLimiter = (props: SearchResultLimiterProps) => { - const { pageLimit, setPageLimit } = useSearch(); - - return ( - - ); -}; diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts index 524b0f9194..5f68905407 100644 --- a/plugins/search-react/src/components/index.ts +++ b/plugins/search-react/src/components/index.ts @@ -20,7 +20,7 @@ export * from './SearchAutocomplete'; export * from './SearchFilter'; export * from './SearchResult'; export * from './SearchResultPager'; -export * from './SearchResultLimiter'; +export * from './SearchPagination'; export * from './SearchResultList'; export * from './SearchResultGroup'; export * from './DefaultResultListItem';