diff --git a/.changeset/search-days-pull.md b/.changeset/search-days-pull.md new file mode 100644 index 0000000000..45d59be514 --- /dev/null +++ b/.changeset/search-days-pull.md @@ -0,0 +1,99 @@ +--- +'@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 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(); + + return ( + +
} /> + + + + + + + + + + + + + + + ); +}; +``` + +_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 = () => ( + + +
} /> + + + + + + + + + + + {({ results }) => ( + ( + + )} + /> + )} + + + + + + +); +``` diff --git a/.changeset/silent-mice-brake.md b/.changeset/silent-mice-brake.md new file mode 100644 index 0000000000..fde02146c9 --- /dev/null +++ b/.changeset/silent-mice-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Add the new search pagination component to the search page template. diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index 99814130c2..d324b683d8 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -35,6 +35,7 @@ import { SearchBar, SearchFilter, SearchResult, + SearchPagination, SearchResultPager, useSearch, } from '@backstage/plugin-search-react'; @@ -129,6 +130,7 @@ const SearchPage = () => { )} + {({ results }) => ( diff --git a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx index 928b8201ea..9f11d0c80c 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx @@ -14,6 +14,7 @@ import { SearchBar, SearchFilter, SearchResult, + SearchPagination, useSearch, } from '@backstage/plugin-search-react'; import { @@ -109,6 +110,7 @@ const SearchPage = () => { + {({ results }) => ( diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index ddfe5d5b39..ef3e8b19bd 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -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; diff --git a/plugins/search-react/src/components/SearchPagination/SearchPagination.stories.tsx b/plugins/search-react/src/components/SearchPagination/SearchPagination.stories.tsx new file mode 100644 index 0000000000..4e72c0fa25 --- /dev/null +++ b/plugins/search-react/src/components/SearchPagination/SearchPagination.stories.tsx @@ -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<{}>) => ( + + + + + + + + + + ), + ], +}; + +export const Default = () => { + return ; +}; + +export const CustomPageLimitLabel = () => { + return ; +}; + +export const CustomPageLimitText = () => { + return ( + `${from}-${to} of more than ${to}`} + /> + ); +}; + +export const CustomPageLimitOptions = () => { + return ; +}; diff --git a/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx b/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx new file mode 100644 index 0000000000..d2ecc55d31 --- /dev/null +++ b/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx @@ -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( + + + + + , + ); + + 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( + + + + + , + ); + + 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( + + + + + , + ); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it('Show the total in text', async () => { + await renderWithEffects( + + + + + , + ); + + expect(screen.getByText('of 100')).toBeInTheDocument(); + }); + + it('Accept custom page limit text', async () => { + await renderWithEffects( + + + `${from}-${to} of more than ${to}`} + /> + + , + ); + + expect(screen.getByText('1-25 of more than 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( + + + + + , + ); + + 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( + + + + + , + ); + + 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 + }), + ); + }); +}); 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..34b6deaff5 --- /dev/null +++ b/plugins/search-react/src/components/SearchPagination/SearchPagination.tsx @@ -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 | 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/SearchPagination/index.ts b/plugins/search-react/src/components/SearchPagination/index.ts new file mode 100644 index 0000000000..74053f0e11 --- /dev/null +++ b/plugins/search-react/src/components/SearchPagination/index.ts @@ -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'; diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts index 62d8b611a4..5f68905407 100644 --- a/plugins/search-react/src/components/index.ts +++ b/plugins/search-react/src/components/index.ts @@ -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';