From 3de4bd4f19670c7c784fa5ed234e76ab06d64d6c Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 6 Oct 2022 09:00:59 +0200 Subject: [PATCH] Create a page limit component Signed-off-by: Camila Belo --- .changeset/search-days-pull.md | 98 ++++++++++++ .../app/src/components/search/SearchPage.tsx | 6 + plugins/search-react/api-report.md | 26 ++++ .../SearchResultLimiter.stories.tsx | 55 +++++++ .../SearchResultLimiter.test.tsx | 118 ++++++++++++++ .../SearchResultLimiter.tsx | 146 ++++++++++++++++++ .../components/SearchResultLimiter/index.ts | 17 ++ plugins/search-react/src/components/index.ts | 1 + 8 files changed, 467 insertions(+) create mode 100644 .changeset/search-days-pull.md create mode 100644 plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.stories.tsx create mode 100644 plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.test.tsx create mode 100644 plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.tsx create mode 100644 plugins/search-react/src/components/SearchResultLimiter/index.ts diff --git a/.changeset/search-days-pull.md b/.changeset/search-days-pull.md new file mode 100644 index 0000000000..72fb9974a4 --- /dev/null +++ b/.changeset/search-days-pull.md @@ -0,0 +1,98 @@ +--- +'@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. + +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, + SearchResultLimiterBase, + SearchResultList, +} from '@backstage/plugin-search-react'; + +const SearchPage = () => { + const [term, setTerm] = useState(''); + const [pageLimit, setPageLimit] = useState(25); + + 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, + SearchResultLimiter, + SearchResultListLayout, + SearchContextProvider, + DefaultResultListItem, +} from '@backstage/plugin-search-react'; + +const SearchPage = () => ( + + +
} /> + + + + + + + + + + + {({ results }) => ( + ( + + )} + /> + )} + + + + + + +); +``` diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index 99814130c2..b97ac99558 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, + SearchResultLimiter, SearchResultPager, useSearch, } from '@backstage/plugin-search-react'; @@ -55,6 +56,10 @@ const useStyles = makeStyles((theme: Theme) => ({ padding: theme.spacing(2), marginTop: theme.spacing(2), }, + limiter: { + width: '100%', + justifyContent: 'flex-end', + }, })); const SearchPage = () => { @@ -129,6 +134,7 @@ const SearchPage = () => { )} + {({ results }) => ( diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index ddfe5d5b39..744f45065f 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -320,6 +320,32 @@ 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?: number[]; + value?: number; + onChange?: (value: number) => void; +}; + +// @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/SearchResultLimiter/SearchResultLimiter.stories.tsx new file mode 100644 index 0000000000..f098f1ab68 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.stories.tsx @@ -0,0 +1,55 @@ +/* + * 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 { SearchResultLimiter } from './SearchResultLimiter'; + +export default { + title: 'Plugins/Search/SearchResultLimiter', + component: SearchResultLimiter, + decorators: [ + (Story: ComponentType<{}>) => ( + + + + + + + + + + ), + ], +}; + +export const Default = () => { + return ; +}; + +export const CustomLabel = () => { + return ; +}; + +export const CustomOptions = () => { + return ; +}; diff --git a/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.test.tsx b/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.test.tsx new file mode 100644 index 0000000000..a6f9f5b1e9 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 { SearchResultLimiter } from './SearchResultLimiter'; + +const query = jest.fn().mockResolvedValue({ results: [] }); + +describe('SearchResultLimiter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Renders without exploding', async () => { + await renderWithEffects( + + + + + , + ); + + expect(screen.getByText('Results per page:')).toBeInTheDocument(); + expect(screen.getByText('25')).toBeInTheDocument(); + }); + + it('Define default 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('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('Accept custom label', async () => { + const label = 'Custom label'; + await renderWithEffects( + + + + + , + ); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it('Accept custom 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'); + }); +}); diff --git a/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.tsx b/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.tsx new file mode 100644 index 0000000000..0d4b8381dd --- /dev/null +++ b/plugins/search-react/src/components/SearchResultLimiter/SearchResultLimiter.tsx @@ -0,0 +1,146 @@ +/* + * 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'; + +/** + * 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?: number[]; + /** + * 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(); + + const handleChange = useCallback( + (newPageLimit: number) => { + setPageLimit(newPageLimit); + }, + [setPageLimit], + ); + + return ( + + ); +}; diff --git a/plugins/search-react/src/components/SearchResultLimiter/index.ts b/plugins/search-react/src/components/SearchResultLimiter/index.ts new file mode 100644 index 0000000000..411e48b5f5 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultLimiter/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 './SearchResultLimiter'; diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts index 62d8b611a4..524b0f9194 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 './SearchResultLimiter'; export * from './SearchResultList'; export * from './SearchResultGroup'; export * from './DefaultResultListItem';