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
+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';