refactor(search-react): use mui table pagination component

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2022-10-08 15:03:43 +02:00
parent 5eb598babb
commit c9198c9fce
9 changed files with 331 additions and 236 deletions
@@ -22,11 +22,11 @@ import { TestApiProvider } from '@backstage/test-utils';
import { searchApiRef, MockSearchApi } from '../../api';
import { SearchContextProvider } from '../../context';
import { SearchResultLimiter } from './SearchResultLimiter';
import { SearchPagination } from './SearchPagination';
export default {
title: 'Plugins/Search/SearchResultLimiter',
component: SearchResultLimiter,
title: 'Plugins/Search/SearchPagination',
component: SearchPagination,
decorators: [
(Story: ComponentType<{}>) => (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
@@ -43,13 +43,17 @@ export default {
};
export const Default = () => {
return <SearchResultLimiter />;
return <SearchPagination />;
};
export const CustomLabel = () => {
return <SearchResultLimiter label="Results limit:" />;
export const CustomPageLimitLabel = () => {
return <SearchPagination pageLimitLabel="Page limit:" />;
};
export const CustomOptions = () => {
return <SearchResultLimiter options={[5, 10, 20]} />;
export const CustomPageLimitText = () => {
return <SearchPagination pageLimitText={({ from, to }) => `${from}-${to}`} />;
};
export const CustomPageLimitOptions = () => {
return <SearchPagination pageLimitOptions={[5, 10, 20]} />;
};
@@ -23,11 +23,15 @@ import { renderWithEffects, TestApiProvider } from '@backstage/test-utils';
import { searchApiRef } from '../../api';
import { SearchContextProvider } from '../../context';
import { SearchResultLimiter } from './SearchResultLimiter';
import { SearchPagination } from './SearchPagination';
const query = jest.fn().mockResolvedValue({ results: [] });
const query = jest.fn().mockResolvedValue({
results: [],
nextPageCursor: 'Mg==',
previousPageCursor: 'MA==',
});
describe('SearchResultLimiter', () => {
describe('SearchPagination', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -36,20 +40,23 @@ describe('SearchResultLimiter', () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter />
<SearchPagination />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText('Results per page:')).toBeInTheDocument();
expect(screen.getByText('25')).toBeInTheDocument();
expect(screen.getByText('1-25 of more than 25')).toBeInTheDocument();
expect(screen.getByLabelText('Next page')).toBeEnabled();
expect(screen.getByLabelText('Previous page')).toBeDisabled();
});
it('Define default options', async () => {
it('Define default page limit options', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter />
<SearchPagination />
</SearchContextProvider>
</TestApiProvider>,
);
@@ -64,11 +71,55 @@ describe('SearchResultLimiter', () => {
expect(options[3]).toHaveTextContent('100');
});
it('Accept custom page limit label', async () => {
const label = 'Page limit:';
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination pageLimitLabel={label} />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText(label)).toBeInTheDocument();
});
it('Accept custom page limit text', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination pageLimitText={({ from, to }) => `${from}-${to}`} />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText('1-25')).toBeInTheDocument();
});
it('Accept custom page limit options', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchPagination pageLimitOptions={[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>
<SearchResultLimiter />
<SearchPagination />
</SearchContextProvider>
</TestApiProvider>,
);
@@ -84,35 +135,40 @@ describe('SearchResultLimiter', () => {
);
});
it('Accept custom label', async () => {
const label = 'Custom label';
it('Set page cursor in the context', async () => {
const initialState = {
term: '',
types: [],
filters: {},
pageCursor: 'MQ==', // page: 1
};
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter label={label} />
<SearchContextProvider initialState={initialState}>
<SearchPagination />
</SearchContextProvider>
</TestApiProvider>,
);
expect(screen.getByText(label)).toBeInTheDocument();
});
await userEvent.click(screen.getByLabelText('Next page'));
it('Accept custom options', async () => {
await renderWithEffects(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchResultLimiter options={[5, 10, 20, 25]} />
</SearchContextProvider>
</TestApiProvider>,
expect(screen.getByText('51-75 of more than 75')).toBeInTheDocument();
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
pageCursor: 'Mg==', // page: 2
}),
);
await userEvent.click(screen.getByText('25'));
await userEvent.click(screen.getByLabelText('Previous page'));
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');
expect(screen.getByText('26-50 of more than 50')).toBeInTheDocument();
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
pageCursor: 'MQ==', // page: 1
}),
);
});
});
@@ -0,0 +1,178 @@
/*
* 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 \}" object.
* @public
*/
export type SearchPaginationLimitText = (params: {
from: number;
to: number;
page: number;
}) => ReactNode;
/**
* Props for {@link SearchPaginationBase}.
* @public
*/
export type SearchPaginationBaseProps = {
/**
* The component class name.
*/
className?: string;
/**
* The cursor for the current page.
*/
pageCursor?: string;
/**
* Callback fired when the current page cursor is changed.
*/
onPageCursorChange?: (pageCursor: string) => void;
/**
* The limit of results per page.
* Set -1 to display all the results.
*/
pageLimit?: number;
/**
* Customize the results per page label.
*/
pageLimitLabel?: ReactNode;
/**
* Customize the results per page text.
*/
pageLimitText?: 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.
*/
pageLimitOptions?: SearchPaginationLimitOption[];
/**
* Callback fired when the number of results per page is changed.
*/
onPageLimitChange?: (value: number) => void;
};
/**
* A component with controls for search results pagination.
* @param props - See {@link SearchPaginationBaseProps}.
* @public
*/
export const SearchPaginationBase = (props: SearchPaginationBaseProps) => {
const {
pageCursor,
onPageCursorChange,
pageLimit: rowsPerPage = 25,
pageLimitLabel: labelRowsPerPage = 'Results per page:',
pageLimitText: labelDisplayedRows,
pageLimitOptions: rowsPerPageOptions,
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={-1}
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}
pageLimit={pageLimit}
onPageLimitChange={setPageLimit}
pageCursor={pageCursor}
onPageCursorChange={setPageCursor}
/>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export * from './SearchResultLimiter';
export * from './SearchPagination';
@@ -1,150 +0,0 @@
/*
* 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';
/**
* A page limit option, this value must not be greater than 100.
* @public
*/
export type SearchResultLimiterOption<
Current extends number = 101,
Accumulator extends number[] = [],
> = Accumulator['length'] extends Current
? Accumulator[number]
: SearchResultLimiterOption<Current, [...Accumulator, Accumulator['length']]>;
/**
* 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?: SearchResultLimiterOption[];
/**
* 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();
return (
<SearchResultLimiterBase
{...props}
value={pageLimit}
onChange={setPageLimit}
/>
);
};
+1 -1
View File
@@ -20,7 +20,7 @@ export * from './SearchAutocomplete';
export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchResultLimiter';
export * from './SearchPagination';
export * from './SearchResultList';
export * from './SearchResultGroup';
export * from './DefaultResultListItem';