Create a page limit component

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2022-10-06 09:00:59 +02:00
parent 32aa6532bf
commit 3de4bd4f19
8 changed files with 467 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
---
'@backstage/plugin-search-react': minor
---
A `<SearchResultLimiter />` 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 (
<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}>
<SearchResultLimiterBase
value={pageLimit}
onChange={setPageLimit}
/>
</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,
SearchResultLimiter,
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}>
<SearchResultLimiter />
</Grid>
<Grid item xs={12}>
<SearchResult>
{({ results }) => (
<SearchResultListLayout
resultItems={results}
renderResultItem={({ document }) => (
<DefaultResultListItem
key={document.location}
result={document}
/>
)}
/>
)}
</SearchResult>
</Grid>
</Grid>
</Content>
</Page>
</SearchContextProvider>
);
```
@@ -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 = () => {
</Grid>
)}
<Grid item xs>
<SearchResultLimiter className={classes.limiter} />
<SearchResult>
{({ results }) => (
<List>
+26
View File
@@ -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;
@@ -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<{}>) => (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
<SearchContextProvider>
<Grid container direction="row">
<Grid item xs={12}>
<Story />
</Grid>
</Grid>
</SearchContextProvider>
</TestApiProvider>
),
],
};
export const Default = () => {
return <SearchResultLimiter />;
};
export const CustomLabel = () => {
return <SearchResultLimiter label="Results limit:" />;
};
export const CustomOptions = () => {
return <SearchResultLimiter options={[5, 10, 20]} />;
};
@@ -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(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText('Results per page:')).toBeInTheDocument();
expect(screen.getByText('25')).toBeInTheDocument();
});
it('Define default options', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter />
</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('Set page limit in the context', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter />
</SearchContextProvider>
</TestApiProvider>,
);
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(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter label={label} />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText(label)).toBeInTheDocument();
});
it('Accept custom options', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter options={[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');
});
});
@@ -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 (
<Box
className={className}
display="inline-grid"
gridGap={theme.spacing(0.5)}
gridAutoFlow="column"
alignItems="center"
>
<Typography id={`${id}-label`} variant="body2">
{label}
</Typography>
<Select
id={`${id}-select`}
labelId={`${id}-label`}
variant="standard"
input={<InputBase />}
value={value}
onChange={handleChange}
>
{[...new Set([DEFAULT_PAGE_LIMIT, ...options])]
.sort((a, b) => a - b)
.map(option => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
</Box>
);
};
/**
* 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 (
<SearchResultLimiterBase
{...props}
value={pageLimit}
onChange={handleChange}
/>
);
};
@@ -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';
@@ -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';