Merge pull request #13968 from backstage/bux/search-per-page-limit-component
[Search] Create a per-page limiter component
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user