chore(helper-plugin): deprecate MORE components (#19718)

This commit is contained in:
Josh 2024-03-11 12:00:00 +00:00 committed by GitHub
parent ab2af1e539
commit af3c6ff7bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1053 additions and 836 deletions

View File

@ -0,0 +1,348 @@
/* eslint-disable import/export */
import * as React from 'react';
import { Flex, SingleSelectOption, SingleSelect, Typography } from '@strapi/design-system';
import {
Dots,
NextLink,
PageLink,
Pagination as PaginationImpl,
PreviousLink,
} from '@strapi/design-system/v2';
import { useQueryParams } from '@strapi/helper-plugin';
import { stringify } from 'qs';
import { useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { createContext } from './Context';
import type { Pagination as PaginationApi } from '../../../shared/contracts/shared';
/* -------------------------------------------------------------------------------------------------
* Root
* -----------------------------------------------------------------------------------------------*/
interface PaginationContextValue {
/**
* @description the complete query object, this could include query params
* injected by other plugins, if you're navigating to a different page you
* should ensure these are still passed.
*/
currentQuery?: object;
pageCount: string;
pageSize: string;
page: string;
setPageSize: (pageSize: string) => void;
total: NonNullable<RootProps['total']>;
}
const [PaginationProvider, usePagination] = createContext<PaginationContextValue>('Pagination');
interface RootProps {
children: React.ReactNode;
/**
* @default 0
* @description the total number of pages
* that exist in the dataset.
*/
pageCount?: PaginationApi['pageCount'];
/**
* @default 1
* @description the initial page number.
*/
defaultPage?: PaginationApi['page'];
/**
* @default 10
* @description the initial number of items to display
*/
defaultPageSize?: PaginationApi['pageSize'];
/**
* @description a callback that is called when the page size changes.
*/
onPageSizeChange?: (pageSize: string) => void;
/**
* @default 0
* @description the total number of items in the dataset.
*/
total?: PaginationApi['total'];
}
/**
* @description The root component for the composable pagination component.
* It's advised to spread the entire pagination option object into this component.
*
* @example
* ```tsx
* const MyComponent = () => {
* return (
* <Pagination.Root {...response.pagination}>
* <Pagination.PageSize />
* <Pagination.Links />
* </Pagination.Root>
* );
* };
* ```
*/
const Root = React.forwardRef<HTMLDivElement, RootProps>(
(
{ children, defaultPageSize = 10, pageCount = 0, defaultPage = 1, onPageSizeChange, total = 0 },
forwardedRef
) => {
const [{ query }, setQuery] = useQueryParams<Pick<PaginationContextValue, 'page' | 'pageSize'>>(
{
pageSize: defaultPageSize.toString(),
page: defaultPage.toString(),
}
);
const setPageSize = (pageSize: string) => {
setQuery({ pageSize, page: '1' });
if (onPageSizeChange) {
onPageSizeChange(pageSize);
}
};
return (
<Flex ref={forwardedRef} paddingTop={4} alignItems="flex-end" justifyContent="space-between">
<PaginationProvider
currentQuery={query}
page={query.page}
pageSize={query.pageSize}
pageCount={pageCount.toString()}
setPageSize={setPageSize}
total={total}
>
{children}
</PaginationProvider>
</Flex>
);
}
);
/* -------------------------------------------------------------------------------------------------
* PageSize
* -----------------------------------------------------------------------------------------------*/
/**
* @description The page size component is responsible for rendering the select input that allows
* the user to change the number of items displayed per page.
* If the total number of items is less than the minimum option, this component will not render.
*/
const PageSize = ({ options = ['10', '20', '50', '100'] }: Pagination.PageSizeProps) => {
const { formatMessage } = useIntl();
const pageSize = usePagination('PageSize', (state) => state.pageSize);
const totalCount = usePagination('PageSize', (state) => state.total);
const setPageSize = usePagination('PageSize', (state) => state.setPageSize);
const handleChange = (value: string) => {
setPageSize(value);
};
const minimumOption = parseInt(options[0], 10);
if (minimumOption >= totalCount) {
return null;
}
return (
<Flex gap={2}>
<SingleSelect
size="S"
aria-label={formatMessage({
id: 'components.PageFooter.select',
defaultMessage: 'Entries per page',
})}
// @ts-expect-error from the DS V2 this won't be needed because we're only returning strings.
onChange={handleChange}
value={pageSize}
>
{options.map((option) => (
<SingleSelectOption key={option} value={option}>
{option}
</SingleSelectOption>
))}
</SingleSelect>
<Typography textColor="neutral600" as="span">
{formatMessage({
id: 'components.PageFooter.select',
defaultMessage: 'Entries per page',
})}
</Typography>
</Flex>
);
};
/* -------------------------------------------------------------------------------------------------
* Links
* -----------------------------------------------------------------------------------------------*/
/**
* The component works as follows
* `1` , 2, 3, ... 10
* 1, `2`, 3, ... 10
* 1, 2, `3`, 4, ... 10
* 1, 2, 3, `4`, 5, ... 10
* 1, ..,4, `5`, 6, ... 10
*
* 1, ...., 8, 9, `10`
* 1, ...., 8, `9`, 10
* 1, ...., 7, `8`, 9, 10
* 1, ... 6, `7`, 8, 9, 10
*/
/**
* @description The links component is responsible for rendering the pagination links.
* If the total number of pages is less than or equal to 1, this component will not render.
*/
const Links = ({ boundaryCount = 1, siblingCount = 1 }: Pagination.LinksProps) => {
const { formatMessage } = useIntl();
const query = usePagination('Links', (state) => state.currentQuery);
const currentPage = usePagination('Links', (state) => state.page);
const totalPages = usePagination('Links', (state) => state.pageCount);
const pageCount = parseInt(totalPages, 10);
const activePage = parseInt(currentPage, 10);
const range = (start: number, end: number) => {
const length = end - start + 1;
return Array.from({ length }, (_, i) => start + i);
};
const startPages = range(1, Math.min(boundaryCount, pageCount));
const endPages = range(Math.max(pageCount - boundaryCount + 1, boundaryCount + 1), pageCount);
const siblingsStart = Math.max(
Math.min(
// Natural start
activePage - siblingCount,
// Lower boundary when page is high
pageCount - boundaryCount - siblingCount * 2 - 1
),
// Greater than startPages
boundaryCount + 2
);
const siblingsEnd = Math.min(
Math.max(
// Natural end
activePage + siblingCount,
// Upper boundary when page is low
boundaryCount + siblingCount * 2 + 2
),
// Less than endPages
endPages.length > 0 ? endPages[0] - 2 : pageCount - 1
);
const items = [
...startPages,
// Start ellipsis
// eslint-disable-next-line no-nested-ternary
...(siblingsStart > boundaryCount + 2
? ['start-ellipsis']
: boundaryCount + 1 < pageCount - boundaryCount
? [boundaryCount + 1]
: []),
// Sibling pages
...range(siblingsStart, siblingsEnd),
// End ellipsis
// eslint-disable-next-line no-nested-ternary
...(siblingsEnd < pageCount - boundaryCount - 1
? ['end-ellipsis']
: pageCount - boundaryCount > boundaryCount
? [pageCount - boundaryCount]
: []),
...endPages,
];
if (pageCount <= 1) {
return null;
}
return (
<PaginationImpl activePage={activePage} pageCount={pageCount}>
<PreviousLink
as={Link}
// @ts-expect-error the `as` prop does not correctly infer the props of it's component
to={{ search: stringify({ ...query, page: activePage - 1 }) }}
>
{formatMessage({
id: 'components.pagination.go-to-previous',
defaultMessage: 'Go to previous page',
})}
</PreviousLink>
{items.map((item) => {
if (typeof item === 'number') {
return (
<PageLink
as={Link}
active={item === activePage}
key={item}
number={item}
// @ts-expect-error the `as` prop does not correctly infer the props of it's component
to={{ search: stringify({ ...query, page: item }) }}
>
{formatMessage(
{ id: 'components.pagination.go-to', defaultMessage: 'Go to page {page}' },
{ page: item }
)}
</PageLink>
);
}
return <Dots key={item} />;
})}
<NextLink
as={Link}
// @ts-expect-error the `as` prop does not correctly infer the props of it's component
to={{ search: stringify({ ...query, page: activePage + 1 }) }}
>
{formatMessage({
id: 'components.pagination.go-to-next',
defaultMessage: 'Go to next page',
})}
</NextLink>
</PaginationImpl>
);
};
/* -------------------------------------------------------------------------------------------------
* EXPORTS
* -----------------------------------------------------------------------------------------------*/
const Pagination = {
Root,
Links,
PageSize,
};
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Pagination {
export interface Props extends RootProps {}
export interface PageSizeProps {
options?: string[];
}
export interface LinksProps {
/**
* @default 1
* @description Number of always visible pages at the beginning and end.
*/
boundaryCount?: number;
/**
* @default 1
* @description Number of always visible pages before and after the current page.
*/
siblingCount?: number;
}
}
export { Pagination };

View File

@ -0,0 +1,76 @@
import * as React from 'react';
import { Duration, intervalToDuration, isPast } from 'date-fns';
import { useIntl } from 'react-intl';
const intervals: Array<keyof Duration> = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];
interface CustomInterval {
unit: keyof Duration;
text: string;
threshold: number;
}
interface RelativeTimeProps extends React.ComponentPropsWithoutRef<'time'> {
timestamp: Date;
customIntervals?: CustomInterval[];
}
/**
* Displays the relative time between a given timestamp and the current time.
* You can display a custom message for given time intervals by passing an array of custom intervals.
*
* @example
* ```jsx
* <caption>Display "last hour" if the timestamp is less than an hour ago</caption>
* <RelativeTime
* timestamp={new Date('2021-01-01')}
* customIntervals={[
* { unit: 'hours', threshold: 1, text: 'last hour' },
* ]}
* ```
*/
const RelativeTime = React.forwardRef<HTMLTimeElement, RelativeTimeProps>(
({ timestamp, customIntervals = [], ...restProps }, forwardedRef) => {
const { formatRelativeTime, formatDate, formatTime } = useIntl();
/**
* TODO: make this auto-update, like a clock.
*/
const interval = intervalToDuration({
start: timestamp,
end: Date.now(),
// see https://github.com/date-fns/date-fns/issues/2891 No idea why it's all partial it returns it every time.
}) as Required<Duration>;
const unit = intervals.find((intervalUnit) => {
return interval[intervalUnit] > 0 && Object.keys(interval).includes(intervalUnit);
})!;
const relativeTime = isPast(timestamp) ? -interval[unit] : interval[unit];
// Display custom text if interval is less than the threshold
const customInterval = customIntervals.find(
(custom) => interval[custom.unit] < custom.threshold
);
const displayText = customInterval
? customInterval.text
: formatRelativeTime(relativeTime, unit, { numeric: 'auto' });
return (
<time
ref={forwardedRef}
dateTime={timestamp.toISOString()}
role="time"
title={`${formatDate(timestamp)} ${formatTime(timestamp)}`}
{...restProps}
>
{displayText}
</time>
);
}
);
export { RelativeTime };
export type { CustomInterval, RelativeTimeProps };

View File

@ -1,13 +1,11 @@
import * as React from 'react';
import { Icon, IconButton, Searchbar, SearchForm } from '@strapi/design-system';
import { useQueryParams, type TrackingEvent, useTracking } from '@strapi/helper-plugin';
import { Search as SearchIcon } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { TrackingEvent, useTracking } from '../features/Tracking';
import { useQueryParams } from '../hooks/useQueryParams';
export interface SearchURLQueryProps {
interface SearchInputProps {
disabled?: boolean;
label: string;
placeholder?: string;
@ -15,13 +13,13 @@ export interface SearchURLQueryProps {
trackedEventDetails?: TrackingEvent['properties'];
}
const SearchURLQuery = ({
const SearchInput = ({
disabled,
label,
placeholder,
trackedEvent,
trackedEventDetails,
}: SearchURLQueryProps) => {
}: SearchInputProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const iconButtonRef = React.useRef<HTMLButtonElement>(null);
@ -67,7 +65,7 @@ const SearchURLQuery = ({
<Searchbar
ref={inputRef}
name="search"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
onChange={(e) => setValue(e.target.value)}
value={value}
clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })}
onClear={handleClear}
@ -91,4 +89,5 @@ const SearchURLQuery = ({
);
};
export { SearchURLQuery };
export { SearchInput };
export type { SearchInputProps };

View File

@ -0,0 +1,266 @@
import { RenderOptions, render as renderRTL, screen, waitFor } from '@tests/utils';
import { useLocation } from 'react-router-dom';
import { Pagination } from '../Pagination';
const LocationDisplay = () => {
const location = useLocation();
return (
<ul>
<li data-testId="location">{location.search}</li>
</ul>
);
};
const render = ({
initialEntries,
options,
...props
}: Partial<Pagination.Props> & Pagination.PageSizeProps & RenderOptions = {}) =>
renderRTL(
<Pagination.Root pageCount={2} total={100} defaultPageSize={50} {...props}>
<Pagination.PageSize options={options} />
<Pagination.Links />
</Pagination.Root>,
{
initialEntries,
renderOptions: {
wrapper({ children }) {
return (
<>
{children}
<LocationDisplay />
</>
);
},
},
}
);
describe('Pagination', () => {
it("doesn't render anything when there is not enough pagination data", () => {
render({ pageCount: 0, total: 0 });
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('renders the complete pagination component when there is enough pagination data', () => {
render();
expect(screen.getByRole('combobox')).toHaveTextContent('50');
expect(screen.getByText('Entries per page')).toBeInTheDocument();
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to previous page' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(screen.getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to next page' })).toHaveAttribute(
'aria-disabled',
'false'
);
});
describe('PageSize', () => {
it('should display the pageSize correctly if its in the url query', () => {
render({
initialEntries: [{ search: 'pageSize=50' }],
});
expect(screen.getByRole('combobox')).toHaveTextContent('50');
});
it('should render a custom list of options if provided', async () => {
const options = ['5', '10', '15'];
const { user } = render({ options, defaultPageSize: 10 });
await waitFor(() => expect(screen.getByRole('combobox')).toHaveTextContent('10'));
await user.click(screen.getByRole('combobox'));
options.forEach((option) => {
expect(screen.getByRole('option', { name: option })).toBeInTheDocument();
});
});
describe('interaction', () => {
it('should change the value when the user selects a new value', async () => {
const { user } = render();
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByRole('option', { name: '20' }));
expect(screen.getByRole('combobox')).toHaveTextContent('20');
const searchParams = new URLSearchParams(screen.getByTestId('location').textContent ?? '');
expect(searchParams.has('pageSize')).toBe(true);
expect(searchParams.get('pageSize')).toBe('20');
});
it('should use the default value and then change the value when the user selects a new value', async () => {
const { getByRole, user } = render({ defaultPageSize: 20 });
expect(getByRole('combobox')).toHaveTextContent('20');
await user.click(getByRole('combobox'));
await user.click(getByRole('option', { name: '50' }));
expect(getByRole('combobox')).toHaveTextContent('50');
});
});
});
describe('Links', () => {
it('should display 4 links when the pageCount is greater than 4', () => {
render({
pageCount: 4,
});
expect(screen.getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to previous page' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(screen.getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 4' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to next page' })).toHaveAttribute(
'aria-disabled',
'false'
);
});
it('should render only 4 links when the page count is 5 or greater', () => {
const { rerender } = render({ pageCount: 5 });
expect(screen.getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to previous page' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(screen.getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 5' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to next page' })).toHaveAttribute(
'aria-disabled',
'false'
);
rerender(
<Pagination.Root total={100} pageCount={10}>
<Pagination.PageSize />
<Pagination.Links />
</Pagination.Root>
);
expect(screen.getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 10' })).toBeInTheDocument();
});
it('should render multiple dot elements when the page count is greater than 5 and the current page is greater than 4', () => {
render({
initialEntries: [{ pathname: '/', search: 'page=5' }],
pageCount: 10,
});
expect(screen.getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 4' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 5' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 6' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to page 10' })).toBeInTheDocument();
});
it('should set the default page to the same as the query', () => {
render({
pageCount: 4,
initialEntries: [{ pathname: '/', search: 'page=3' }],
});
expect(screen.getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
expect(screen.getByTestId('location')).toHaveTextContent('page=3');
expect(screen.getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to previous page' })).toHaveAttribute(
'aria-disabled',
'false'
);
expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to next page' })).toHaveAttribute(
'aria-disabled',
'false'
);
});
it('should change the page correctly when the user clicks on a page link', async () => {
const { user } = render({
pageCount: 4,
initialEntries: [{ pathname: '/', search: 'page=3' }],
});
expect(screen.getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
await user.click(screen.getByRole('link', { name: 'Go to page 2' }));
expect(screen.getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(screen.getByTestId('location')).toHaveTextContent('page=2');
});
it('should change the page correctly when the user clicks on the arrows', async () => {
const { user } = render({
pageCount: 4,
initialEntries: [{ pathname: '/', search: 'page=3' }],
});
expect(screen.getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
await user.click(screen.getByRole('link', { name: 'Go to previous page' }));
expect(screen.getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(screen.getByTestId('location')).toHaveTextContent('page=2');
await user.click(screen.getByRole('link', { name: 'Go to next page' }));
await user.click(screen.getByRole('link', { name: 'Go to next page' }));
expect(screen.getByRole('link', { name: 'Go to page 4' })).toBeInTheDocument();
expect(screen.getByTestId('location')).toHaveTextContent('page=4');
});
it('should carry the rest of the query params when the user clicks on a page link', async () => {
const { user } = render({
pageCount: 4,
initialEntries: [{ pathname: '/', search: 'page=3&search=foo' }],
});
expect(screen.getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
await user.click(screen.getByRole('link', { name: 'Go to page 2' }));
expect(screen.getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(screen.getByTestId('location')).toHaveTextContent('page=2');
expect(screen.getByRole('link', { name: 'Go to page 4' })).toHaveAttribute(
'href',
'/?pageSize=50&page=4&search=foo'
);
});
});
});

View File

@ -0,0 +1,47 @@
import { render, screen } from '@tests/utils';
import { RelativeTime } from '../RelativeTime';
const setDateNow = (now: number): jest.Spied<typeof Date.now> =>
jest.spyOn(Date, 'now').mockReturnValue(now);
let spiedDateNow: ReturnType<typeof setDateNow> | undefined = undefined;
describe('RelativeTime', () => {
afterAll(() => {
if (spiedDateNow) {
spiedDateNow.mockReset();
}
});
it('renders and matches the snapshot', () => {
spiedDateNow = setDateNow(1443686400000); // 2015-10-01 08:00:00
render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(screen.getByRole('time')).toMatchInlineSnapshot(`
<time
datetime="2015-10-01T07:55:00.000Z"
role="time"
title="10/1/2015 7:55 AM"
>
5 minutes ago
</time>
`);
});
it('can display the relative time for a future date', () => {
spiedDateNow = setDateNow(1443685800000); // 2015-10-01 07:50:00
render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(screen.getByRole('time')).toHaveTextContent('in 5 minutes');
// expect(getByText('in 5 minutes')).toBeInTheDocument();
});
it('can display the relative time for a past date', () => {
spiedDateNow = setDateNow(1443686400000); // 2015-10-01 08:00:00
render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(screen.getByRole('time')).toHaveTextContent('5 minutes ago');
// expect(getByText('5 minutes ago')).toBeInTheDocument();
});
});

View File

@ -1,14 +1,7 @@
import { render } from '@tests/utils';
import { useLocation } from 'react-router-dom';
import { SearchURLQuery } from '../SearchURLQuery';
const trackUsage = jest.fn();
jest.mock('../../features/Tracking', () => ({
useTracking: () => ({
trackUsage,
}),
}));
import { SearchInput } from '../SearchInput';
const LocationDisplay = () => {
const location = useLocation();
@ -20,15 +13,15 @@ const LocationDisplay = () => {
);
};
describe('<SearchURLQuery />', () => {
describe('SearchInput', () => {
it('should render an icon button by default', () => {
const { getByRole } = render(<SearchURLQuery label="Search label" />);
const { getByRole } = render(<SearchInput label="Search label" />);
expect(getByRole('button', { name: 'Search' })).toBeInTheDocument();
});
it('should toggle searchbar form and searchbar', async () => {
const { user, getByRole } = render(<SearchURLQuery label="Search label" />);
const { user, getByRole } = render(<SearchInput label="Search label" />);
await user.click(getByRole('button', { name: 'Search' }));
@ -36,7 +29,7 @@ describe('<SearchURLQuery />', () => {
});
it('should push value to query params', async () => {
const { user, getByRole } = render(<SearchURLQuery label="Search label" />, {
const { user, getByRole } = render(<SearchInput label="Search label" />, {
renderOptions: {
wrapper({ children }) {
return (
@ -63,7 +56,7 @@ describe('<SearchURLQuery />', () => {
});
it('should clear value and update query params', async () => {
const { user, getByRole } = render(<SearchURLQuery label="Search label" />, {
const { user, getByRole } = render(<SearchInput label="Search label" />, {
renderOptions: {
wrapper({ children }) {
return (
@ -90,16 +83,4 @@ describe('<SearchURLQuery />', () => {
expect(new URLSearchParams(getByRole('listitem').textContent ?? '').has('_q')).toBe(false);
});
it('should call trackUsage with trackedEvent props when submit', async () => {
const { getByRole, user } = render(
<SearchURLQuery label="Search label" trackedEvent="didSearch" />
);
await user.click(getByRole('button', { name: 'Search' }));
await user.type(getByRole('textbox', { name: 'Search label' }), 'michka');
await user.keyboard('[Enter]');
expect(trackUsage.mock.calls.length).toBe(1);
});
});

View File

@ -1,11 +1,12 @@
import * as React from 'react';
import { Box, Flex, Typography, type BoxProps } from '@strapi/design-system';
import { RelativeTime, useQueryParams } from '@strapi/helper-plugin';
import { useQueryParams } from '@strapi/helper-plugin';
import { stringify } from 'qs';
import { type MessageDescriptor, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { RelativeTime } from '../../../components/RelativeTime';
import { getDisplayName } from '../../utils/users';
import { useHistoryContext } from '../pages/History';

View File

@ -9,7 +9,7 @@ import {
Typography,
} from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import { RelativeTime, useNotification, useQueryParams, useStrapiApp } from '@strapi/helper-plugin';
import { useNotification, useQueryParams, useStrapiApp } from '@strapi/helper-plugin';
import { ArrowLeft, Cog, ExclamationMarkCircle, Pencil, Trash } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useMatch, useNavigate } from 'react-router-dom';
@ -17,6 +17,7 @@ import styled from 'styled-components';
import { DescriptionComponentRenderer } from '../../../../components/DescriptionComponentRenderer';
import { useForm } from '../../../../components/Form';
import { RelativeTime } from '../../../../components/RelativeTime';
import { capitalise } from '../../../../utils/strings';
import {
CREATED_AT_ATTRIBUTE_NAME,

View File

@ -16,15 +16,12 @@ import {
} from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import {
SearchURLQuery,
useFocusWhenNavigate,
useQueryParams,
useNotification,
useTracking,
useAPIErrorHandler,
useStrapiApp,
PaginationURLQuery,
PageSizeURLQuery,
} from '@strapi/helper-plugin';
import { ArrowLeft, Plus } from '@strapi/icons';
import { stringify } from 'qs';
@ -34,6 +31,8 @@ import styled from 'styled-components';
import { InjectionZone } from '../../../components/InjectionZone';
import { Page } from '../../../components/PageHelpers';
import { Pagination } from '../../../components/Pagination';
import { SearchInput } from '../../../components/SearchInput';
import { HOOKS } from '../../../constants';
import { useEnterprise } from '../../../hooks/useEnterprise';
import { capitalise } from '../../../utils/strings';
@ -303,7 +302,7 @@ const ListViewPage = () => {
startActions={
<>
{list.settings.searchable && (
<SearchURLQuery
<SearchInput
disabled={results.length === 0}
label={formatMessage(
{ id: 'app.component.search.label', defaultMessage: 'Search for {target}' },
@ -459,10 +458,13 @@ const ListViewPage = () => {
</Table.Body>
</Table.Content>
</Table.Root>
<Flex alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery trackedEvent="willChangeNumberOfEntriesPerPage" />
<PaginationURLQuery pagination={{ pageCount: pagination?.pageCount || 1 }} />
</Flex>
<Pagination.Root
{...pagination}
onPageSizeChange={() => trackUsage('willChangeNumberOfEntriesPerPage')}
>
<Pagination.PageSize />
<Pagination.Links />
</Pagination.Root>
</Flex>
</ContentLayout>
</Main>

View File

@ -10,6 +10,8 @@ export * from './render';
export * from './components/Form';
export * from './components/FormInputs/Renderer';
export * from './components/PageHelpers';
export * from './components/Pagination';
export * from './components/SearchInput';
/**
* Hooks

View File

@ -15,8 +15,6 @@ import {
Tabs,
} from '@strapi/design-system';
import {
PageSizeURLQuery,
PaginationURLQuery,
useAppInfo,
useFocusWhenNavigate,
useNotification,
@ -29,6 +27,7 @@ import { useIntl } from 'react-intl';
import { ContentBox } from '../../components/ContentBox';
import { Page } from '../../components/PageHelpers';
import { Pagination } from '../../components/Pagination';
import { useTypedSelector } from '../../core/store/hooks';
import { useDebounce } from '../../hooks/useDebounce';
@ -269,14 +268,10 @@ const MarketplacePage = () => {
</TabPanel>
</TabPanels>
</TabGroup>
{pagination ? (
<Box paddingTop={4}>
<Flex alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery options={['12', '24', '50', '100']} defaultValue="24" />
<PaginationURLQuery pagination={pagination} />
</Flex>
</Box>
) : null}
<Pagination.Root {...pagination} defaultPageSize={24}>
<Pagination.PageSize options={['12', '24', '50', '100']} />
<Pagination.Links />
</Pagination.Root>
<Box paddingTop={8}>
<a
href="https://strapi.canny.io/plugin-requests"

View File

@ -28,7 +28,7 @@ const waitForReload = async () => {
const LocationDisplay = () => {
const location = useLocation();
return <span>{location.search}</span>;
return <span data-testId="location">{location.search}</span>;
};
const render = () =>
@ -330,7 +330,7 @@ describe('Marketplace page - plugins tab', () => {
await waitForReload();
expect(screen.getByText('?page=1')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(`"?page=1"`);
});
it('only filters in the plugins tab', async () => {
@ -378,7 +378,9 @@ describe('Marketplace page - plugins tab', () => {
await user.click(getByRole('option', { name: 'Newest' }));
await waitForReload();
expect(screen.getByText('?sort=submissionDate:desc&page=1')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?sort=submissionDate:desc&page=1"`
);
});
it('shows github stars and weekly downloads count for each plugin', async () => {
@ -417,16 +419,22 @@ describe('Marketplace page - plugins tab', () => {
// Can go to next page
await user.click(getByText(/go to next page/i).closest('a')!);
await waitForReload();
expect(screen.getByText('?page=2')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?pageSize=24&page=2"`
);
// Can go to previous page
await user.click(getByText(/go to previous page/i).closest('a')!);
await waitForReload();
expect(screen.getByText('?page=1')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?pageSize=24&page=1"`
);
// Can go to specific page
await user.click(getByText(/go to page 3/i).closest('a')!);
await waitForReload();
expect(screen.getByText('?page=3')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?pageSize=24&page=3"`
);
});
});

View File

@ -30,7 +30,7 @@ jest.mock('@strapi/helper-plugin', () => ({
const LocationDisplay = () => {
const location = useLocation();
return <span>{location.search}</span>;
return <span data-testId="location">{location.search}</span>;
};
const render = () =>
@ -221,7 +221,7 @@ describe('Marketplace page - providers tab', () => {
});
it('removes a filter option tag', async () => {
const { getByRole, user, getByText } = render();
const { getByRole, user } = render();
await waitForReload();
@ -240,7 +240,9 @@ describe('Marketplace page - providers tab', () => {
await user.click(getByRole('button', { name: 'Made by Strapi' }));
await waitForReload();
expect(getByText('?npmPackageType=provider&sort=name:asc&page=1')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?npmPackageType=provider&sort=name:asc&page=1"`
);
});
it('only filters in the providers tab', async () => {
@ -296,9 +298,9 @@ describe('Marketplace page - providers tab', () => {
await waitForReload();
expect(
screen.getByText('?npmPackageType=provider&sort=submissionDate:desc&page=1')
).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?npmPackageType=provider&sort=submissionDate:desc&page=1"`
);
});
it('shows github stars and weekly downloads count for each provider', async () => {
@ -340,16 +342,22 @@ describe('Marketplace page - providers tab', () => {
// Can go to next page
await user.click(getByText(/go to next page/i).closest('a')!);
await waitForReload();
expect(screen.getByText('?npmPackageType=provider&sort=name:asc&page=2')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?pageSize=24&page=2&npmPackageType=provider&sort=name%3Aasc"`
);
// Can go to previous page
await user.click(getByText(/go to previous page/i).closest('a')!);
await waitForReload();
expect(screen.getByText('?npmPackageType=provider&sort=name:asc&page=1')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?pageSize=24&page=1&npmPackageType=provider&sort=name%3Aasc"`
);
// Can go to specific page
await user.click(getByText(/go to page 3/i).closest('a')!);
await waitForReload();
expect(screen.getByText('?npmPackageType=provider&sort=name:asc&page=3')).toBeInTheDocument();
expect(screen.getByTestId('location').textContent).toMatchInlineSnapshot(
`"?pageSize=24&page=3&npmPackageType=provider&sort=name%3Aasc"`
);
});
});

View File

@ -7,7 +7,6 @@ import {
DynamicTable,
onRowClick,
pxToRem,
RelativeTime,
useQueryParams,
useTracking,
TableProps as DynamicTableProps,
@ -20,6 +19,7 @@ import styled from 'styled-components';
import { ApiToken } from '../../../../../../shared/contracts/api-token';
import { SanitizedTransferToken } from '../../../../../../shared/contracts/transfer';
import { RelativeTime } from '../../../../components/RelativeTime';
import type { Entity } from '@strapi/types';

View File

@ -18,7 +18,6 @@ import {
import {
ConfirmDialog,
getFetchClient,
SearchURLQuery,
useAPIErrorHandler,
useFocusWhenNavigate,
useQueryParams,
@ -33,6 +32,7 @@ import { useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom';
import { Page } from '../../../../components/PageHelpers';
import { SearchInput } from '../../../../components/SearchInput';
import { useTypedSelector } from '../../../../core/store/hooks';
import { useAdminRoles, AdminRole } from '../../../../hooks/useAdminRoles';
import { selectAdminPermissions } from '../../../../selectors';
@ -165,7 +165,7 @@ const ListPage = () => {
{canRead && (
<ActionLayout
startActions={
<SearchURLQuery
<SearchInput
label={formatMessage(
{ id: 'app.component.search.label', defaultMessage: 'Search for {target}' },
{

View File

@ -7,18 +7,14 @@ import {
Main,
Flex,
Typography,
Box,
Status,
} from '@strapi/design-system';
import {
DynamicTable,
SearchURLQuery,
useAPIErrorHandler,
useFocusWhenNavigate,
useNotification,
useRBAC,
PageSizeURLQuery,
PaginationURLQuery,
TableHeader,
} from '@strapi/helper-plugin';
import * as qs from 'qs';
@ -28,6 +24,8 @@ import { useLocation } from 'react-router-dom';
import { SanitizedAdminUser } from '../../../../../../shared/contracts/shared';
import { Page } from '../../../../components/PageHelpers';
import { Pagination } from '../../../../components/Pagination';
import { SearchInput } from '../../../../components/SearchInput';
import { useTypedSelector } from '../../../../core/store/hooks';
import { useEnterprise } from '../../../../hooks/useEnterprise';
import { useAdminUsers, useDeleteManyUsersMutation } from '../../../../services/users';
@ -111,7 +109,7 @@ const ListPageCE = () => {
<ActionLayout
startActions={
<>
<SearchURLQuery
<SearchInput
label={formatMessage(
{ id: 'app.component.search.label', defaultMessage: 'Search for {target}' },
{ target: title }
@ -157,15 +155,10 @@ const ListPageCE = () => {
>
<TableRows canDelete={canDelete} />
</DynamicTable>
{pagination && (
<Box paddingTop={4}>
<Flex alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery />
<PaginationURLQuery pagination={pagination} />
</Flex>
</Box>
)}
<Pagination.Root {...pagination}>
<Pagination.PageSize />
<Pagination.Links />
</Pagination.Root>
</>
)}
</ContentLayout>

View File

@ -1,22 +1,15 @@
import {
ActionLayout,
Box,
ContentLayout,
HeaderLayout,
Layout,
Main,
} from '@strapi/design-system';
import { ActionLayout, ContentLayout, HeaderLayout, Main } from '@strapi/design-system';
import { DynamicTable, useFocusWhenNavigate, useQueryParams, useRBAC } from '@strapi/helper-plugin';
import { Helmet } from 'react-helmet';
import { useIntl } from 'react-intl';
import { Page } from '../../../../../../../admin/src/components/PageHelpers';
import { Pagination } from '../../../../../../../admin/src/components/Pagination';
import { useTypedSelector } from '../../../../../../../admin/src/core/store/hooks';
import { Filters } from '../../../../../../../admin/src/pages/Settings/components/Filters';
import { SanitizedAdminUserForAuditLogs } from '../../../../../../../shared/contracts/audit-logs';
import { Modal } from './components/Modal';
import { PaginationFooter } from './components/PaginationFooter';
import { TableHeader, TableRows } from './components/TableRows';
import { useAuditLogsData } from './hooks/useAuditLogsData';
import { getDisplayedFilters } from './utils/getDisplayedFilters';
@ -130,7 +123,10 @@ const ListPage = () => {
onOpenModal={(id) => setQuery({ id: `${id}` })}
/>
</DynamicTable>
{auditLogs?.pagination && <PaginationFooter pagination={auditLogs.pagination} />}
<Pagination.Root {...auditLogs?.pagination} defaultPageSize={24}>
<Pagination.PageSize options={['12', '24', '50', '100']} />
<Pagination.Links />
</Pagination.Root>
</ContentLayout>
{query?.id && <Modal handleClose={() => setQuery({ id: null }, 'remove')} logId={query.id} />}
</Main>

View File

@ -1,30 +0,0 @@
import React from 'react';
import { Box, Flex } from '@strapi/design-system';
import { PageSizeURLQuery, PaginationURLQuery } from '@strapi/helper-plugin';
import { Pagination } from '../../../../../../../../shared/contracts/shared';
type PaginationFooterProps = {
pagination: Pagination;
};
export const PaginationFooter = (
{ pagination }: PaginationFooterProps = {
pagination: {
page: 1,
pageCount: 0,
pageSize: 50,
total: 0,
},
}
) => {
return (
<Box paddingTop={4}>
<Flex alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery />
<PaginationURLQuery pagination={pagination} />
</Flex>
</Box>
);
};

View File

@ -1,5 +1,6 @@
import { within } from '@testing-library/react';
import { render, screen, waitFor } from '@tests/utils';
import { render, screen, server, waitFor } from '@tests/utils';
import { rest } from 'msw';
import { ListPage } from '../ListPage';
@ -44,6 +45,28 @@ describe('ADMIN | Pages | AUDIT LOGS | ListPage', () => {
});
expect(screen.getAllByRole('gridcell', { name: 'test testing' })).toHaveLength(2);
});
it('should have pagination when theres enough data', async () => {
server.use(
rest.get('/admin/audit-logs', (req, res, ctx) => {
return res(
ctx.json({
results: [],
pagination: {
page: 1,
pageSize: 10,
pageCount: 5,
total: 50,
},
})
);
})
);
render(<ListPage />);
await waitFor(() => expect(screen.queryByText('Loading content...')).not.toBeInTheDocument());
expect(screen.getByRole('combobox', { name: 'Entries per page' })).toBeInTheDocument();

View File

@ -0,0 +1,76 @@
import * as React from 'react';
import { Duration, intervalToDuration, isPast } from 'date-fns';
import { useIntl } from 'react-intl';
const intervals: Array<keyof Duration> = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];
interface CustomInterval {
unit: keyof Duration;
text: string;
threshold: number;
}
interface RelativeTimeProps extends React.ComponentPropsWithoutRef<'time'> {
timestamp: Date;
customIntervals?: CustomInterval[];
}
/**
* Displays the relative time between a given timestamp and the current time.
* You can display a custom message for given time intervals by passing an array of custom intervals.
*
* @example
* ```jsx
* <caption>Display "last hour" if the timestamp is less than an hour ago</caption>
* <RelativeTime
* timestamp={new Date('2021-01-01')}
* customIntervals={[
* { unit: 'hours', threshold: 1, text: 'last hour' },
* ]}
* ```
*/
const RelativeTime = React.forwardRef<HTMLTimeElement, RelativeTimeProps>(
({ timestamp, customIntervals = [], ...restProps }, forwardedRef) => {
const { formatRelativeTime, formatDate, formatTime } = useIntl();
/**
* TODO: make this auto-update, like a clock.
*/
const interval = intervalToDuration({
start: timestamp,
end: Date.now(),
// see https://github.com/date-fns/date-fns/issues/2891 No idea why it's all partial it returns it every time.
}) as Required<Duration>;
const unit = intervals.find((intervalUnit) => {
return interval[intervalUnit] > 0 && Object.keys(interval).includes(intervalUnit);
})!;
const relativeTime = isPast(timestamp) ? -interval[unit] : interval[unit];
// Display custom text if interval is less than the threshold
const customInterval = customIntervals.find(
(custom) => interval[custom.unit] < custom.threshold
);
const displayText = customInterval
? customInterval.text
: formatRelativeTime(relativeTime, unit, { numeric: 'auto' });
return (
<time
ref={forwardedRef}
dateTime={timestamp.toISOString()}
role="time"
title={`${formatDate(timestamp)} ${formatTime(timestamp)}`}
{...restProps}
>
{displayText}
</time>
);
}
);
export { RelativeTime };
export type { CustomInterval, RelativeTimeProps };

View File

@ -0,0 +1,47 @@
import { render, screen } from '@tests/utils';
import { RelativeTime } from '../RelativeTime';
const setDateNow = (now: number): jest.Spied<typeof Date.now> =>
jest.spyOn(Date, 'now').mockReturnValue(now);
let spiedDateNow: ReturnType<typeof setDateNow> | undefined = undefined;
describe('RelativeTime', () => {
afterAll(() => {
if (spiedDateNow) {
spiedDateNow.mockReset();
}
});
it('renders and matches the snapshot', () => {
spiedDateNow = setDateNow(1443686400000); // 2015-10-01 08:00:00
render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(screen.getByRole('time')).toMatchInlineSnapshot(`
<time
datetime="2015-10-01T07:55:00.000Z"
role="time"
title="10/1/2015 7:55 AM"
>
5 minutes ago
</time>
`);
});
it('can display the relative time for a future date', () => {
spiedDateNow = setDateNow(1443685800000); // 2015-10-01 07:50:00
render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(screen.getByRole('time')).toHaveTextContent('in 5 minutes');
// expect(getByText('in 5 minutes')).toBeInTheDocument();
});
it('can display the relative time for a past date', () => {
spiedDateNow = setDateNow(1443686400000); // 2015-10-01 08:00:00
render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(screen.getByRole('time')).toHaveTextContent('5 minutes ago');
// expect(getByText('5 minutes ago')).toBeInTheDocument();
});
});

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { Page, unstable_useDocument } from '@strapi/admin/strapi-admin';
import { Page, unstable_useDocument, Pagination } from '@strapi/admin/strapi-admin';
import {
Button,
ContentLayout,
@ -22,9 +22,6 @@ import {
import { LinkButton, Menu } from '@strapi/design-system/v2';
import {
CheckPermissions,
PageSizeURLQuery,
PaginationURLQuery,
RelativeTime,
Table,
useAPIErrorHandler,
useNotification,
@ -48,6 +45,7 @@ import { useIntl } from 'react-intl';
import { useParams, useNavigate, Link as ReactRouterLink, Navigate } from 'react-router-dom';
import styled from 'styled-components';
import { RelativeTime } from '../components/RelativeTime';
import { ReleaseActionMenu } from '../components/ReleaseActionMenu';
import { ReleaseActionOptions } from '../components/ReleaseActionOptions';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
@ -819,14 +817,13 @@ const ReleaseDetailsBody = ({ releaseId }: ReleaseDetailsBodyProps) => {
</Table.Root>
</Flex>
))}
<Flex paddingTop={4} alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery defaultValue={releaseMeta?.pagination?.pageSize.toString()} />
<PaginationURLQuery
pagination={{
pageCount: releaseMeta?.pagination?.pageCount || 0,
}}
/>
</Flex>
<Pagination.Root
{...releaseMeta?.pagination}
defaultPageSize={releaseMeta?.pagination?.pageSize}
>
<Pagination.PageSize />
<Pagination.Links />
</Pagination.Root>
</Flex>
</ContentLayout>
);

View File

@ -1,7 +1,7 @@
import * as React from 'react';
// TODO: Replace this import with the same hook exported from the @strapi/admin/strapi-admin/ee in another iteration of this solution
import { Page, useLicenseLimits } from '@strapi/admin/strapi-admin';
import { Page, Pagination, useLicenseLimits } from '@strapi/admin/strapi-admin';
import {
Alert,
Badge,
@ -25,13 +25,10 @@ import {
import { Link } from '@strapi/design-system/v2';
import {
CheckPermissions,
PageSizeURLQuery,
PaginationURLQuery,
useQueryParams,
useAPIErrorHandler,
useNotification,
useTracking,
RelativeTime,
} from '@strapi/helper-plugin';
import { EmptyDocuments, Plus } from '@strapi/icons';
import { useIntl } from 'react-intl';
@ -39,6 +36,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { GetReleases, type Release } from '../../../shared/contracts/releases';
import { RelativeTime } from '../components/RelativeTime';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants';
import { isAxiosError } from '../services/axios';
@ -404,19 +402,13 @@ const ReleasesPage = () => {
</TabPanel>
</TabPanels>
</TabGroup>
{response.currentData?.meta?.pagination?.total ? (
<Flex paddingTop={4} alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery
options={['8', '16', '32', '64']}
defaultValue={response?.currentData?.meta?.pagination?.pageSize.toString()}
/>
<PaginationURLQuery
pagination={{
pageCount: response?.currentData?.meta?.pagination?.pageCount || 0,
}}
/>
</Flex>
) : null}
<Pagination.Root
{...response?.currentData?.meta?.pagination}
defaultPageSize={response?.currentData?.meta?.pagination?.pageSize}
>
<Pagination.PageSize options={['8', '16', '32', '64']} />
<Pagination.Links />
</Pagination.Root>
</>
</ContentLayout>
{releaseModalShown && (

View File

@ -236,10 +236,70 @@ const MyComponent = (props) => {
};
```
### PageSizeURLQuery
This component was moved to the `admin` package and can now be imported via the `@strapi/strapi` package as part of the composite component `Pagination`:
```tsx
// Before
import { PageSizeURLQuery } from '@strapi/helper-plugin';
// After
import { Pagination } from '@strapi/strapi/admin';
const MyComponent = () => {
return (
<Pagination.Root>
<Pagination.PageSize />
</Pagination.Root>
);
};
```
Note, there were some slightly behavioural changes i.e. the PageSize won't render if the lowest pageSize is 10 but you only have 9 entries. Due to the refactor some props will have moved and changed, please look at the documentation for the Pagination component for more info.
### PaginationURLQueryProps
This component was moved to the `admin` package and can now be imported via the `@strapi/strapi` package as part of the composite component `Pagination`:
```tsx
// Before
import { PaginationURLQueryProps } from '@strapi/helper-plugin';
// After
import { Pagination } from '@strapi/strapi/admin';
const MyComponent = () => {
return (
<Pagination.Root pageCount={2}>
<Pagination.Links />
</Pagination.Root>
);
};
```
Note, there were some slightly behavioural changes i.e. the Links won't render if there are less than 2 pages. Due to the refactor some props will have moved and changed, please look at the documentation for the Pagination component for more info.
### ReactSelect
This component has been removed and not replaced. If you feel like you need this component, please open an issue on the Strapi repository to discuss your usecase.
### RelativeTime
This component has been removed and not replaced. If you feel like you need this component, please open an issue on the Strapi repository to discuss your usecase.
### SearchURLQuery
This component was removed and renamed to `SearchInput` and can now be imported by the `@strapi/strapi` package:
```tsx
// Before
import { SearchURLQuery } from '@strapi/helper-plugin';
// After
import { SearchInput } from '@strapi/strapi/admin';
```
### SettingsPageTitle
This component has been removed and not replaced. If you feel like you need this component, please open an issue on the Strapi repository to discuss your usecase.

View File

@ -1,26 +0,0 @@
import * as React from 'react';
import { Flex, Loader } from '@strapi/design-system';
import styled from 'styled-components';
const Wrapper = styled(Flex)`
height: 100vh;
`;
interface LoadingIndicatorPageProps {
children?: React.ReactNode;
'data-testid'?: string;
}
const LoadingIndicatorPage = ({
children = 'Loading content.',
'data-testid': dataTestId = 'loader',
}: LoadingIndicatorPageProps) => {
return (
<Wrapper justifyContent="space-around" data-testid={dataTestId}>
<Loader>{children}</Loader>
</Wrapper>
);
};
export { LoadingIndicatorPage };

View File

@ -1,70 +0,0 @@
/**
*
* PageSizeURLQuery
*
*/
import * as React from 'react';
import { Flex, SingleSelectOption, SingleSelect, Typography } from '@strapi/design-system';
import { useIntl } from 'react-intl';
import { useTracking, TrackingEvent } from '../features/Tracking';
import { useQueryParams } from '../hooks/useQueryParams';
export interface PageSizeURLQueryProps {
trackedEvent?: Extract<TrackingEvent, { properties?: never }>['name'];
options?: string[];
defaultValue?: string;
}
export const PageSizeURLQuery = ({
trackedEvent,
options = ['10', '20', '50', '100'],
defaultValue = '10',
}: PageSizeURLQueryProps) => {
const { formatMessage } = useIntl();
const [{ query }, setQuery] = useQueryParams<{ pageSize?: string; page: number }>();
const { trackUsage } = useTracking();
const handleChange = (value: string) => {
if (trackedEvent) {
trackUsage(trackedEvent);
}
setQuery({
pageSize: value,
page: 1,
});
};
const pageSize: string =
typeof query?.pageSize === 'string' && query?.pageSize !== '' ? query.pageSize : defaultValue;
return (
<Flex gap={2}>
<SingleSelect
size="S"
aria-label={formatMessage({
id: 'components.PageFooter.select',
defaultMessage: 'Entries per page',
})}
// @ts-expect-error from the DS V2 this won't be needed because we're only returning strings.
onChange={handleChange}
value={pageSize}
>
{options.map((option) => (
<SingleSelectOption key={option} value={option}>
{option}
</SingleSelectOption>
))}
</SingleSelect>
<Typography textColor="neutral600" as="span">
{formatMessage({
id: 'components.PageFooter.select',
defaultMessage: 'Entries per page',
})}
</Typography>
</Flex>
);
};

View File

@ -1,146 +0,0 @@
/**
* Pagination
*
* The component works as follows
* `1` , 2, 3, ... 10
* 1, `2`, 3, ... 10
* 1, 2, `3`, 4, ... 10
* 1, 2, 3, `4`, 5, ... 10
* 1, ..,4, `5`, 6, ... 10
*
* 1, ...., 8, 9, `10`
* 1, ...., 8, `9`, 10
* 1, ...., 7, `8`, 9, 10
* 1, ... 6, `7`, 8, 9, 10
*/
import { Dots, NextLink, PageLink, Pagination, PreviousLink } from '@strapi/design-system';
import { stringify } from 'qs';
import { useIntl } from 'react-intl';
import { useLocation } from 'react-router-dom';
import { useQueryParams } from '../hooks/useQueryParams';
type PaginationURLQueryProps = {
/**
* Number of always visible pages at the beginning and end.
* @default 1
*/
boundaryCount?: number;
pagination: {
pageCount: number;
};
/**
* Number of always visible pages before and after the current page.
* @default 1
*/
siblingCount?: number;
};
export const PaginationURLQuery = ({
pagination: { pageCount },
boundaryCount = 1,
siblingCount = 1,
}: PaginationURLQueryProps) => {
const [{ query }] = useQueryParams<{ page: string }>();
const activePage = parseInt(query?.page || '1', 10);
const { pathname } = useLocation();
const { formatMessage } = useIntl();
const makeSearch = (page: number) => stringify({ ...query, page }, { encode: false });
const nextSearch = makeSearch(activePage + (pageCount > 1 ? 1 : 0));
const previousSearch = makeSearch(activePage - 1);
const range = (start: number, end: number) => {
const length = end - start + 1;
return Array.from({ length }, (_, i) => start + i);
};
const startPages = range(1, Math.min(boundaryCount, pageCount));
const endPages = range(Math.max(pageCount - boundaryCount + 1, boundaryCount + 1), pageCount);
const siblingsStart = Math.max(
Math.min(
// Natural start
activePage - siblingCount,
// Lower boundary when page is high
pageCount - boundaryCount - siblingCount * 2 - 1
),
// Greater than startPages
boundaryCount + 2
);
const siblingsEnd = Math.min(
Math.max(
// Natural end
activePage + siblingCount,
// Upper boundary when page is low
boundaryCount + siblingCount * 2 + 2
),
// Less than endPages
endPages.length > 0 ? endPages[0] - 2 : pageCount - 1
);
const items = [
...startPages,
// Start ellipsis
// eslint-disable-next-line no-nested-ternary
...(siblingsStart > boundaryCount + 2
? ['start-ellipsis']
: boundaryCount + 1 < pageCount - boundaryCount
? [boundaryCount + 1]
: []),
// Sibling pages
...range(siblingsStart, siblingsEnd),
// End ellipsis
// eslint-disable-next-line no-nested-ternary
...(siblingsEnd < pageCount - boundaryCount - 1
? ['end-ellipsis']
: pageCount - boundaryCount > boundaryCount
? [pageCount - boundaryCount]
: []),
...endPages,
];
return (
<Pagination activePage={activePage} pageCount={pageCount}>
<PreviousLink active={false} to={{ pathname, search: previousSearch }}>
{formatMessage({
id: 'components.pagination.go-to-previous',
defaultMessage: 'Go to previous page',
})}
</PreviousLink>
{items.map((item) => {
if (typeof item === 'number') {
return (
<PageLink
active={item === activePage}
key={item}
number={item}
to={{ pathname, search: makeSearch(item) }}
>
{formatMessage(
{ id: 'components.pagination.go-to', defaultMessage: 'Go to page {page}' },
{ page: item }
)}
</PageLink>
);
}
return <Dots key={item} />;
})}
<NextLink active={false} to={{ pathname, search: nextSearch }}>
{formatMessage({
id: 'components.pagination.go-to-next',
defaultMessage: 'Go to next page',
})}
</NextLink>
</Pagination>
);
};

View File

@ -1,65 +0,0 @@
import { Duration, intervalToDuration, isPast } from 'date-fns';
import { useIntl } from 'react-intl';
const intervals: Array<keyof Duration> = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];
interface CustomInterval {
unit: keyof Duration;
text: string;
threshold: number;
}
export interface RelativeTimeProps {
timestamp: Date;
customIntervals?: CustomInterval[];
className?: string;
}
/**
* Displays the relative time between a given timestamp and the current time.
* You can display a custom message for given time intervals by passing an array of custom intervals.
*
* @example
* ```jsx
* <caption>Display "last hour" if the timestamp is less than an hour ago</caption>
* <RelativeTime
* timestamp={new Date('2021-01-01')}
* customIntervals={[
* { unit: 'hours', threshold: 1, text: 'last hour' },
* ]}
* ```
*/
const RelativeTime = ({ timestamp, customIntervals = [], className }: RelativeTimeProps) => {
const { formatRelativeTime, formatDate, formatTime } = useIntl();
const interval = intervalToDuration({
start: timestamp,
end: Date.now(),
// see https://github.com/date-fns/date-fns/issues/2891 No idea why it's all partial it returns it every time.
}) as Required<Duration>;
const unit = intervals.find((intervalUnit) => {
return interval[intervalUnit] > 0 && Object.keys(interval).includes(intervalUnit);
})!;
const relativeTime = isPast(timestamp) ? -interval[unit] : interval[unit];
// Display custom text if interval is less than the threshold
const customInterval = customIntervals.find((custom) => interval[custom.unit] < custom.threshold);
const displayText = customInterval
? customInterval.text
: formatRelativeTime(relativeTime, unit, { numeric: 'auto' });
return (
<time
dateTime={timestamp.toISOString()}
title={`${formatDate(timestamp)} ${formatTime(timestamp)}`}
className={className}
>
{displayText}
</time>
);
};
export { RelativeTime };

View File

@ -1,121 +0,0 @@
/**
*
* Tests for PageSizeURLQuery
*
*/
import * as React from 'react';
import { render } from '@tests/utils';
import { useLocation } from 'react-router-dom';
import { PageSizeURLQuery } from '../PageSizeURLQuery';
const trackUsage = jest.fn();
jest.mock('../../features/Tracking', () => ({
useTracking: () => ({
trackUsage,
}),
}));
const LocationDisplay = () => {
const location = useLocation();
return (
<ul>
<li>{location.search}</li>
</ul>
);
};
describe('PageSizeURLQuery', () => {
it('renders', async () => {
const { getByRole, getAllByRole, getByText, user } = render(<PageSizeURLQuery />);
expect(getByRole('combobox')).toHaveTextContent('10');
expect(getByText('Entries per page')).toBeInTheDocument();
await user.click(getByRole('combobox'));
expect(getAllByRole('option')).toHaveLength(4);
});
it('should display the default pageSize correctly if passed as a prop', () => {
const { getByRole } = render(<PageSizeURLQuery defaultValue="20" />);
expect(getByRole('combobox')).toHaveTextContent('20');
});
it('should display the pageSize correctly if its in the url query', () => {
const { getByRole } = render(<PageSizeURLQuery />, {
initialEntries: [{ search: 'pageSize=50' }],
});
expect(getByRole('combobox')).toHaveTextContent('50');
});
it('should render a custom list of options if provided', async () => {
const options = ['5', '10', '15'];
const { getByRole, user } = render(<PageSizeURLQuery options={options} />);
expect(getByRole('combobox')).toHaveTextContent('10');
await user.click(getByRole('combobox'));
options.forEach((option) => {
expect(getByRole('option', { name: option })).toBeInTheDocument();
});
});
describe('interaction', () => {
it('should change the value when the user selects a new value', async () => {
const { getByRole, user } = render(<PageSizeURLQuery />, {
renderOptions: {
wrapper({ children }) {
return (
<>
{children}
<LocationDisplay />
</>
);
},
},
});
await user.click(getByRole('combobox'));
await user.click(getByRole('option', { name: '20' }));
expect(getByRole('combobox')).toHaveTextContent('20');
const searchParams = new URLSearchParams(getByRole('listitem').textContent ?? '');
expect(searchParams.has('pageSize')).toBe(true);
expect(searchParams.get('pageSize')).toBe('20');
});
it('should use the default value and then change the value when the user selects a new value', async () => {
const { getByRole, user } = render(<PageSizeURLQuery defaultValue="20" />);
expect(getByRole('combobox')).toHaveTextContent('20');
await user.click(getByRole('combobox'));
await user.click(getByRole('option', { name: '50' }));
expect(getByRole('combobox')).toHaveTextContent('50');
});
it('should call trackUsage with trackedEvent props when submit', async () => {
const { getByRole, user } = render(<PageSizeURLQuery trackedEvent="didCreateRole" />);
await user.click(getByRole('combobox'));
await user.click(getByRole('option', { name: '50' }));
expect(trackUsage.mock.calls.length).toBe(1);
});
});
});

View File

@ -1,161 +0,0 @@
import { render } from '@tests/utils';
import { PaginationURLQuery } from '../PaginationURLQuery';
describe('PaginationURLQuery', () => {
it('renders when there is only one page', () => {
const { getByRole } = render(<PaginationURLQuery pagination={{ pageCount: 1 }} />);
expect(getByRole('navigation')).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to previous page' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 1' })).toHaveAttribute('aria-current', 'page');
expect(getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to next page' })).toHaveAttribute('aria-disabled', 'true');
});
it('should display 4 links when the pageCount is greater than 4', () => {
const { getByRole } = render(<PaginationURLQuery pagination={{ pageCount: 4 }} />);
expect(getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to previous page' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 1' })).toHaveAttribute('aria-current', 'page');
expect(getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 4' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to next page' })).toHaveAttribute(
'aria-disabled',
'false'
);
});
it('should render only 4 links when the page count is 5 or greater', () => {
const { getByRole, rerender } = render(<PaginationURLQuery pagination={{ pageCount: 5 }} />);
expect(getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to previous page' })).toHaveAttribute(
'aria-disabled',
'true'
);
expect(getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 1' })).toHaveAttribute('aria-current', 'page');
expect(getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 5' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to next page' })).toHaveAttribute(
'aria-disabled',
'false'
);
rerender(<PaginationURLQuery pagination={{ pageCount: 10 }} />);
expect(getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 10' })).toBeInTheDocument();
});
it('should render multiple dot elements when the page count is greater than 5 and the current page is greater than 4', () => {
const { getByRole } = render(<PaginationURLQuery pagination={{ pageCount: 10 }} />, {
initialEntries: [{ pathname: '/', search: 'page=5' }],
});
expect(getByRole('link', { name: 'Go to page 1' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 4' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 5' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 5' })).toHaveAttribute('aria-current', 'page');
expect(getByRole('link', { name: 'Go to page 6' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 10' })).toBeInTheDocument();
});
it('should set the default page to the same as the query', () => {
const { getByRole } = render(<PaginationURLQuery pagination={{ pageCount: 4 }} />, {
initialEntries: [{ pathname: '/', search: 'page=3' }],
});
expect(getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 3' })).toHaveAttribute('aria-current', 'page');
expect(getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to previous page' })).toHaveAttribute(
'aria-disabled',
'false'
);
expect(getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to next page' })).toHaveAttribute(
'aria-disabled',
'false'
);
});
it('should change the page correctly when the user clicks on a page link', async () => {
const { getByRole, user } = render(<PaginationURLQuery pagination={{ pageCount: 4 }} />, {
initialEntries: [{ pathname: '/', search: 'page=3' }],
});
expect(getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 3' })).toHaveAttribute('aria-current', 'page');
await user.click(getByRole('link', { name: 'Go to page 2' }));
expect(getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 2' })).toHaveAttribute('aria-current', 'page');
});
it('should change the page correctly when the user clicks on the arrows', async () => {
const { getByRole, user } = render(<PaginationURLQuery pagination={{ pageCount: 4 }} />, {
initialEntries: [{ pathname: '/', search: 'page=3' }],
});
expect(getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 3' })).toHaveAttribute('aria-current', 'page');
await user.click(getByRole('link', { name: 'Go to previous page' }));
expect(getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 2' })).toHaveAttribute('aria-current', 'page');
await user.click(getByRole('link', { name: 'Go to next page' }));
await user.click(getByRole('link', { name: 'Go to next page' }));
expect(getByRole('link', { name: 'Go to page 4' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 4' })).toHaveAttribute('aria-current', 'page');
});
it('should carry the rest of the query params when the user clicks on a page link', async () => {
const { getByRole, user } = render(<PaginationURLQuery pagination={{ pageCount: 4 }} />, {
initialEntries: [{ pathname: '/', search: 'page=3&search=foo' }],
});
expect(getByRole('link', { name: 'Go to page 3' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 3' })).toHaveAttribute('aria-current', 'page');
await user.click(getByRole('link', { name: 'Go to page 2' }));
expect(getByRole('link', { name: 'Go to page 2' })).toBeInTheDocument();
expect(getByRole('link', { name: 'Go to page 2' })).toHaveAttribute('aria-current', 'page');
expect(getByRole('link', { name: 'Go to page 4' })).toHaveAttribute(
'href',
'/?page=4&search=foo'
);
});
});

View File

@ -1,44 +0,0 @@
import { render } from '@tests/utils';
import { RelativeTime } from '../RelativeTime';
export function setDateNow(now: number): jest.Spied<typeof Date.now> {
return jest.spyOn(Date, 'now').mockReturnValue(now);
}
let spiedDateNow: jest.Spied<typeof Date.now> | undefined = undefined;
describe('RelativeTime', () => {
afterAll(() => {
spiedDateNow?.mockReset();
});
it('renders and matches the snapshot', () => {
spiedDateNow = setDateNow(1443686400000); // 2015-10-01 08:00:00
const {
container: { firstChild },
} = render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(firstChild).toMatchInlineSnapshot(`
<time
datetime="2015-10-01T07:55:00.000Z"
title="10/1/2015 7:55 AM"
>
5 minutes ago
</time>
`);
});
it('can display the relative time for a future date', () => {
spiedDateNow = setDateNow(1443685800000); // 2015-10-01 07:50:00
const { getByText } = render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(getByText('in 5 minutes')).toBeInTheDocument();
});
it('can display the relative time for a past date', () => {
spiedDateNow = setDateNow(1443686400000); // 2015-10-01 08:00:00
const { getByText } = render(<RelativeTime timestamp={new Date('2015-10-01 07:55:00')} />);
expect(getByText('5 minutes ago')).toBeInTheDocument();
});
});

View File

@ -14,7 +14,7 @@ const useQueryParams = <TQuery extends object>(initialParams?: TQuery) => {
return initialParams;
}
return parse(searchQuery) as TQuery;
return { ...initialParams, ...parse(searchQuery) } as TQuery;
}, [search, initialParams]);
const setQuery = useCallback(

View File

@ -9,10 +9,6 @@ export * from './components/Table';
export * from './components/EmptyBodyTable';
export * from './components/FilterListURLQuery';
export * from './components/FilterPopoverURLQuery';
export * from './components/PageSizeURLQuery';
export * from './components/PaginationURLQuery';
export * from './components/RelativeTime';
export * from './components/SearchURLQuery';
/* -------------------------------------------------------------------------------------------------
* Features

View File

@ -22,7 +22,7 @@ const makeApp = (queryValue) => (
</ThemeProvider>
);
describe('<SearchURLQuery />', () => {
describe('SearchAsset', () => {
it('renders and matches the snapshot', () => {
const { container } = render(makeApp(null));

View File

@ -1,33 +0,0 @@
import React from 'react';
import { Box, Flex } from '@strapi/design-system';
import { PageSizeURLQuery, PaginationURLQuery } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
export const PaginationFooter = ({ pagination }) => {
return (
<Box paddingTop={6}>
<Flex alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery />
<PaginationURLQuery pagination={pagination} />
</Flex>
</Box>
);
};
PaginationFooter.defaultProps = {
pagination: {
pageCount: 0,
pageSize: 10,
total: 0,
},
};
PaginationFooter.propTypes = {
pagination: PropTypes.shape({
page: PropTypes.number,
pageCount: PropTypes.number,
pageSize: PropTypes.number,
total: PropTypes.number,
}),
};

View File

@ -1,6 +1,6 @@
import React, { useRef, useState } from 'react';
import { Page } from '@strapi/admin/strapi-admin';
import { Page, SearchInput, Pagination } from '@strapi/admin/strapi-admin';
import {
ActionLayout,
BaseCheckbox,
@ -17,7 +17,6 @@ import {
} from '@strapi/design-system';
import {
CheckPermissions,
SearchURLQuery,
useFocusWhenNavigate,
usePersistentState,
useQueryParams,
@ -40,7 +39,6 @@ import {
FolderCardCheckbox,
} from '../../../components/FolderCard';
import { FolderGridList } from '../../../components/FolderGridList';
import { PaginationFooter } from '../../../components/PaginationFooter';
import SortPicker from '../../../components/SortPicker';
import { TableList } from '../../../components/TableList';
import { UploadAssetDialog } from '../../../components/UploadAssetDialog/UploadAssetDialog';
@ -298,7 +296,7 @@ export const MediaLibrary = () => {
onClick={() => setView(isGridView ? viewOptions.LIST : viewOptions.GRID)}
/>
</ActionContainer>
<SearchURLQuery
<SearchInput
label={formatMessage({
id: getTrad('search.label'),
defaultMessage: 'Search for an asset',
@ -478,8 +476,10 @@ export const MediaLibrary = () => {
)}
</>
)}
{assetsData?.pagination && <PaginationFooter pagination={assetsData.pagination} />}
<Pagination.Root {...assetsData.pagination}>
<Pagination.PageSize />
<Pagination.Links />
</Pagination.Root>
</ContentLayout>
</Main>

View File

@ -21,7 +21,6 @@ import { LinkButton } from '@strapi/design-system/v2';
import {
CheckPermissions,
ConfirmDialog,
SearchURLQuery,
useFocusWhenNavigate,
useNotification,
useQueryParams,
@ -29,7 +28,7 @@ import {
useTracking,
} from '@strapi/helper-plugin';
import { Plus } from '@strapi/icons';
import { Page } from '@strapi/strapi/admin';
import { Page, SearchInput } from '@strapi/strapi/admin';
import { Helmet } from 'react-helmet';
import { useIntl } from 'react-intl';
import { useMutation, useQuery } from 'react-query';
@ -172,7 +171,7 @@ export const RolesListPage = () => {
<ActionLayout
startActions={
<SearchURLQuery
<SearchInput
label={formatMessage({
id: 'app.component.search.label',
defaultMessage: 'Search',