From 98429bf89e5341a51f5565e8393b468c072948de Mon Sep 17 00:00:00 2001 From: Qi <474021214@qq.com> Date: Tue, 12 Sep 2023 11:37:59 +0800 Subject: [PATCH] feat: support pagination for member list (#4231) --- .../new-workspace-setting-detail/members.tsx | 139 +++++++++++++----- .../new-workspace-setting-detail/style.css.ts | 22 ++- .../core/src/hooks/affine/use-member-count.ts | 13 ++ apps/core/src/hooks/affine/use-members.ts | 8 +- .../affine/use-revoke-member-permission.ts | 3 +- .../server/src/modules/workspaces/resolver.ts | 13 +- apps/server/src/schema.gql | 2 +- apps/server/src/tests/utils.ts | 6 +- apps/server/src/tests/workspace-invite.e2e.ts | 31 ++++ packages/component/package.json | 1 + .../components/member-components/index.tsx | 1 + .../member-components/invite-modal.tsx | 52 +------ .../member-components/pagination.tsx | 49 ++++++ .../member-components/styles.css.tsx | 47 +++++- .../get-member-count-by-workspace-id.gql | 5 + .../graphql/get-members-by-workspace-id.gql | 4 +- packages/graphql/src/graphql/index.ts | 17 ++- packages/graphql/src/schema.ts | 16 ++ packages/i18n/src/resources/en.json | 1 + tests/affine-cloud/e2e/collaboration.spec.ts | 68 ++++++++- yarn.lock | 14 +- 21 files changed, 404 insertions(+), 108 deletions(-) create mode 100644 apps/core/src/hooks/affine/use-member-count.ts create mode 100644 packages/component/src/components/member-components/pagination.tsx create mode 100644 packages/graphql/src/graphql/get-member-count-by-workspace-id.gql diff --git a/apps/core/src/components/affine/new-workspace-setting-detail/members.tsx b/apps/core/src/components/affine/new-workspace-setting-detail/members.tsx index 3f105aeb6b..2226680e0c 100644 --- a/apps/core/src/components/affine/new-workspace-setting-detail/members.tsx +++ b/apps/core/src/components/affine/new-workspace-setting-detail/members.tsx @@ -2,6 +2,10 @@ import { InviteModal, type InviteModalProps, } from '@affine/component/member-components'; +import { + Pagination, + type PaginationProps, +} from '@affine/component/member-components'; import { pushNotificationAtom } from '@affine/component/notification-center'; import { SettingRow } from '@affine/component/setting-components'; import type { AffineOfficialWorkspace } from '@affine/env/workspace'; @@ -11,10 +15,11 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { MoreVerticalIcon } from '@blocksuite/icons'; import { Avatar } from '@toeverything/components/avatar'; import { Button, IconButton } from '@toeverything/components/button'; +import { Loading } from '@toeverything/components/loading'; import { Menu, MenuItem } from '@toeverything/components/menu'; import { Tooltip } from '@toeverything/components/tooltip'; import clsx from 'clsx'; -import { useSetAtom } from 'jotai/react'; +import { useSetAtom } from 'jotai'; import type { ReactElement } from 'react'; import { Suspense, useCallback, useMemo, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; @@ -22,16 +27,18 @@ import { ErrorBoundary } from 'react-error-boundary'; import type { CheckedUser } from '../../../hooks/affine/use-current-user'; import { useCurrentUser } from '../../../hooks/affine/use-current-user'; import { useInviteMember } from '../../../hooks/affine/use-invite-member'; +import { useMemberCount } from '../../../hooks/affine/use-member-count'; import { type Member, useMembers } from '../../../hooks/affine/use-members'; import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission'; import { AnyErrorBoundary } from '../any-error-boundary'; import * as style from './style.css'; import type { WorkspaceSettingDetailProps } from './types'; +const COUNT_PER_PAGE = 8; export interface MembersPanelProps extends WorkspaceSettingDetailProps { workspace: AffineOfficialWorkspace; } - +type OnRevoke = (memberId: string) => void; const MembersPanelLocal = () => { const t = useAFFiNEI18N(); return ( @@ -48,41 +55,27 @@ const MembersPanelLocal = () => { export const CloudWorkspaceMembersPanel = ({ workspace, isOwner, -}: MembersPanelProps): ReactElement => { +}: MembersPanelProps) => { const workspaceId = workspace.id; - const members = useMembers(workspaceId); + const memberCount = useMemberCount(workspaceId); + const t = useAFFiNEI18N(); - const currentUser = useCurrentUser(); const { invite, isMutating } = useInviteMember(workspaceId); - const [open, setOpen] = useState(false); - const pushNotification = useSetAtom(pushNotificationAtom); const revokeMemberPermission = useRevokeMemberPermission(workspaceId); - const memberCount = members.length; - const memberList = useMemo( - () => - members.sort((a, b) => { - if ( - a.permission === Permission.Owner && - b.permission !== Permission.Owner - ) { - return -1; - } - if ( - a.permission !== Permission.Owner && - b.permission === Permission.Owner - ) { - return 1; - } - return 0; - }), - [members] - ); + const [open, setOpen] = useState(false); + const [memberSkip, setMemberSkip] = useState(0); + + const pushNotification = useSetAtom(pushNotificationAtom); const openModal = useCallback(() => { setOpen(true); }, []); + const onPageChange = useCallback(offset => { + setMemberSkip(offset); + }, []); + const onInviteConfirm = useCallback( async ({ email, permission }) => { const success = await invite( @@ -103,11 +96,25 @@ export const CloudWorkspaceMembersPanel = ({ [invite, pushNotification, t] ); + const onRevoke = useCallback( + async memberId => { + const res = await revokeMemberPermission(memberId); + if (res?.revoke) { + pushNotification({ + title: t['Removed successfully'](), + type: 'success', + }); + } + }, + [pushNotification, revokeMemberPermission, t] + ); + return ( <> {isOwner ? ( <> @@ -121,21 +128,78 @@ export const CloudWorkspaceMembersPanel = ({ ) : null} -
- {memberList.map(member => ( - + }> + - ))} + + +
); }; +const MemberListFallback = ({ memberCount }: { memberCount: number }) => { + // prevent page jitter + const height = useMemo(() => { + if (memberCount > COUNT_PER_PAGE) { + // height and margin-bottom + return COUNT_PER_PAGE * 58 + (COUNT_PER_PAGE - 1) * 6; + } + return 'auto'; + }, [memberCount]); + + return ( +
+ +
+ ); +}; + +const MemberList = ({ + workspaceId, + isOwner, + skip, + onRevoke, +}: { + workspaceId: string; + isOwner: boolean; + skip: number; + onRevoke: OnRevoke; +}) => { + const members = useMembers(workspaceId, skip, COUNT_PER_PAGE); + const currentUser = useCurrentUser(); + + return ( + <> + {members.map(member => ( + + ))} + + ); +}; + const MemberItem = ({ member, isOwner, @@ -145,7 +209,7 @@ const MemberItem = ({ member: Member; isOwner: boolean; currentUser: CheckedUser; - onRevoke: (memberId: string) => void; + onRevoke: OnRevoke; }) => { const t = useAFFiNEI18N(); @@ -162,7 +226,7 @@ const MemberItem = ({ return ( <> -
+
; -export function useMembers(workspaceId: string) { +export function useMembers( + workspaceId: string, + skip: number, + take: number = 8 +) { const { data } = useQuery({ query: getMembersByWorkspaceIdQuery, variables: { workspaceId, + skip, + take, }, }); return data.workspace.members; diff --git a/apps/core/src/hooks/affine/use-revoke-member-permission.ts b/apps/core/src/hooks/affine/use-revoke-member-permission.ts index 379c90713b..569b184eff 100644 --- a/apps/core/src/hooks/affine/use-revoke-member-permission.ts +++ b/apps/core/src/hooks/affine/use-revoke-member-permission.ts @@ -12,11 +12,12 @@ export function useRevokeMemberPermission(workspaceId: string) { return useCallback( async (userId: string) => { - await trigger({ + const res = await trigger({ workspaceId, userId, }); await mutate(); + return res; }, [mutate, trigger, workspaceId] ); diff --git a/apps/server/src/modules/workspaces/resolver.ts b/apps/server/src/modules/workspaces/resolver.ts index bd639952e4..e36ab8d85c 100644 --- a/apps/server/src/modules/workspaces/resolver.ts +++ b/apps/server/src/modules/workspaces/resolver.ts @@ -177,7 +177,6 @@ export class WorkspaceResolver { return this.prisma.userWorkspacePermission.count({ where: { workspaceId: workspace.id, - accepted: true, }, }); } @@ -210,15 +209,25 @@ export class WorkspaceResolver { description: 'Members of workspace', complexity: 2, }) - async members(@Parent() workspace: WorkspaceType) { + async members( + @Parent() workspace: WorkspaceType, + @Args('skip', { type: () => Int, nullable: true }) skip?: number, + @Args('take', { type: () => Int, nullable: true }) take?: number + ) { const data = await this.prisma.userWorkspacePermission.findMany({ where: { workspaceId: workspace.id, }, + skip, + take: take || 8, + orderBy: { + type: 'desc', + }, include: { user: true, }, }); + return data .filter(({ user }) => !!user) .map(({ id, accepted, type, user }) => ({ diff --git a/apps/server/src/schema.gql b/apps/server/src/schema.gql index 8f30b3b477..d652079964 100644 --- a/apps/server/src/schema.gql +++ b/apps/server/src/schema.gql @@ -98,7 +98,7 @@ type WorkspaceType { createdAt: DateTime! """Members of workspace""" - members: [InviteUserType!]! + members(skip: Int, take: Int): [InviteUserType!]! """Permission of current signed in user in workspace""" permission: Permission! diff --git a/apps/server/src/tests/utils.ts b/apps/server/src/tests/utils.ts index 4025a4e1a8..88e988e53d 100644 --- a/apps/server/src/tests/utils.ts +++ b/apps/server/src/tests/utils.ts @@ -119,7 +119,9 @@ export async function getWorkspaceSharedPages( async function getWorkspace( app: INestApplication, token: string, - workspaceId: string + workspaceId: string, + skip = 0, + take = 8 ): Promise { const res = await request(app.getHttpServer()) .post(gql) @@ -129,7 +131,7 @@ async function getWorkspace( query: ` query { workspace(id: "${workspaceId}") { - id, members { id, name, email, permission, inviteId } + id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId } } } `, diff --git a/apps/server/src/tests/workspace-invite.e2e.ts b/apps/server/src/tests/workspace-invite.e2e.ts index 2809be93de..6f45dd3742 100644 --- a/apps/server/src/tests/workspace-invite.e2e.ts +++ b/apps/server/src/tests/workspace-invite.e2e.ts @@ -247,3 +247,34 @@ test('should send email', async t => { } t.pass(); }); + +test('should support pagination for member', async t => { + const { app } = t.context; + const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); + const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1'); + const u3 = await signUp(app, 'u3', 'u3@affine.pro', '1'); + + const workspace = await createWorkspace(app, u1.token.token); + await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin'); + await inviteUser(app, u1.token.token, workspace.id, u3.email, 'Admin'); + + await acceptInvite(app, u2.token.token, workspace.id); + await acceptInvite(app, u3.token.token, workspace.id); + + const firstPageWorkspace = await getWorkspace( + app, + u1.token.token, + workspace.id, + 0, + 2 + ); + t.is(firstPageWorkspace.members.length, 2, 'failed to check invite id'); + const secondPageWorkspace = await getWorkspace( + app, + u1.token.token, + workspace.id, + 2, + 2 + ); + t.is(secondPageWorkspace.members.length, 1, 'failed to check invite id'); +}); diff --git a/packages/component/package.json b/packages/component/package.json index cf3631bf38..12a085b62a 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -49,6 +49,7 @@ "react-dom": "18.2.0", "react-error-boundary": "^4.0.11", "react-is": "^18.2.0", + "react-paginate": "^8.2.0", "rxjs": "^7.8.1" }, "devDependencies": { diff --git a/packages/component/src/components/member-components/index.tsx b/packages/component/src/components/member-components/index.tsx index bb5d6d82c0..5612a4b5dc 100644 --- a/packages/component/src/components/member-components/index.tsx +++ b/packages/component/src/components/member-components/index.tsx @@ -1,2 +1,3 @@ export * from './accept-invite-page'; export * from './invite-modal'; +export * from './pagination'; diff --git a/packages/component/src/components/member-components/invite-modal.tsx b/packages/component/src/components/member-components/invite-modal.tsx index b5479f2256..f558f3f2db 100644 --- a/packages/component/src/components/member-components/invite-modal.tsx +++ b/packages/component/src/components/member-components/invite-modal.tsx @@ -15,50 +15,6 @@ export interface InviteModalProps { isMutating: boolean; } -const PermissionMenu = ({ - currentPermission, - onChange, -}: { - currentPermission: Permission; - onChange: (permission: Permission) => void; -}) => { - console.log('currentPermission', currentPermission); - console.log('onChange', onChange); - - return null; - // return ( - // - // {Object.entries(Permission).map(([permission]) => { - // return ( - // { - // onChange(permission as Permission); - // }} - // > - // {permission} - // - // ); - // })} - // - // } - // > - // - // {currentPermission} - // - // - // ); -}; - export const InviteModal = ({ open, setOpen, @@ -67,7 +23,7 @@ export const InviteModal = ({ }: InviteModalProps) => { const t = useAFFiNEI18N(); const [inviteEmail, setInviteEmail] = useState(''); - const [permission, setPermission] = useState(Permission.Write); + const [permission] = useState(Permission.Write); const [isValidEmail, setIsValidEmail] = useState(true); const handleCancel = useCallback(() => { @@ -125,12 +81,6 @@ export const InviteModal = ({ onEnter={handleConfirm} style={{ marginTop: 20 }} size="large" - endFix={ - - } />
diff --git a/packages/component/src/components/member-components/pagination.tsx b/packages/component/src/components/member-components/pagination.tsx new file mode 100644 index 0000000000..9c9273db4d --- /dev/null +++ b/packages/component/src/components/member-components/pagination.tsx @@ -0,0 +1,49 @@ +import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons'; +import clsx from 'clsx'; +import { useCallback, useMemo } from 'react'; +import ReactPaginate from 'react-paginate'; + +import * as styles from './styles.css'; +export interface PaginationProps { + totalCount: number; + countPerPage: number; + onPageChange: (skip: number) => void; +} + +export const Pagination = ({ + totalCount, + countPerPage, + onPageChange, +}: PaginationProps) => { + const handlePageClick = useCallback( + (e: { selected: number }) => { + const newOffset = (e.selected * countPerPage) % totalCount; + onPageChange(newOffset); + }, + [countPerPage, onPageChange, totalCount] + ); + + const pageCount = useMemo( + () => Math.ceil(totalCount / countPerPage), + [countPerPage, totalCount] + ); + + return ( + } + nextLabel={} + pageClassName={styles.pageItem} + previousClassName={clsx(styles.pageItem, 'label')} + nextClassName={clsx(styles.pageItem, 'label')} + breakLabel="..." + breakClassName={styles.pageItem} + containerClassName={styles.pagination} + activeClassName="active" + renderOnZeroPageCount={null} + /> + ); +}; diff --git a/packages/component/src/components/member-components/styles.css.tsx b/packages/component/src/components/member-components/styles.css.tsx index 5bcc77ffea..dd6e1365c3 100644 --- a/packages/component/src/components/member-components/styles.css.tsx +++ b/packages/component/src/components/member-components/styles.css.tsx @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; export const inviteModalTitle = style({ fontWeight: '600', @@ -21,3 +21,48 @@ export const inviteName = style({ marginRight: '10px', color: 'var(--affine-black)', }); + +export const pagination = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '6px', + marginTop: 5, +}); + +export const pageItem = style({ + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + width: '20px', + height: '20px', + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-primary-color)', + borderRadius: '4px', + + selectors: { + '&:hover': { + background: 'var(--affine-hover-color)', + }, + '&.active': { + color: 'var(--affine-primary-color)', + cursor: 'default', + pointerEvents: 'none', + }, + '&.label': { + color: 'var(--affine-icon-color)', + fontSize: '16px', + }, + '&.disabled': { + opacity: '.4', + cursor: 'default', + color: 'var(--affine-disable-color)', + pointerEvents: 'none', + }, + }, +}); +globalStyle(`${pageItem} a`, { + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', +}); diff --git a/packages/graphql/src/graphql/get-member-count-by-workspace-id.gql b/packages/graphql/src/graphql/get-member-count-by-workspace-id.gql new file mode 100644 index 0000000000..ce7073d269 --- /dev/null +++ b/packages/graphql/src/graphql/get-member-count-by-workspace-id.gql @@ -0,0 +1,5 @@ +query getMemberCountByWorkspaceId($workspaceId: String!) { + workspace(id: $workspaceId) { + memberCount + } +} diff --git a/packages/graphql/src/graphql/get-members-by-workspace-id.gql b/packages/graphql/src/graphql/get-members-by-workspace-id.gql index 216df27e5e..4d416aa148 100644 --- a/packages/graphql/src/graphql/get-members-by-workspace-id.gql +++ b/packages/graphql/src/graphql/get-members-by-workspace-id.gql @@ -1,6 +1,6 @@ -query getMembersByWorkspaceId($workspaceId: String!) { +query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { workspace(id: $workspaceId) { - members { + members(skip: $skip, take: $take) { id name email diff --git a/packages/graphql/src/graphql/index.ts b/packages/graphql/src/graphql/index.ts index fce5cea8dc..5a16eab4d7 100644 --- a/packages/graphql/src/graphql/index.ts +++ b/packages/graphql/src/graphql/index.ts @@ -204,15 +204,28 @@ query getIsOwner($workspaceId: String!) { }`, }; +export const getMemberCountByWorkspaceIdQuery = { + id: 'getMemberCountByWorkspaceIdQuery' as const, + operationName: 'getMemberCountByWorkspaceId', + definitionName: 'workspace', + containsFile: false, + query: ` +query getMemberCountByWorkspaceId($workspaceId: String!) { + workspace(id: $workspaceId) { + memberCount + } +}`, +}; + export const getMembersByWorkspaceIdQuery = { id: 'getMembersByWorkspaceIdQuery' as const, operationName: 'getMembersByWorkspaceId', definitionName: 'workspace', containsFile: false, query: ` -query getMembersByWorkspaceId($workspaceId: String!) { +query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { workspace(id: $workspaceId) { - members { + members(skip: $skip, take: $take) { id name email diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index 0dbb1bc67a..e772d0c847 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -206,8 +206,19 @@ export type GetIsOwnerQueryVariables = Exact<{ export type GetIsOwnerQuery = { __typename?: 'Query'; isOwner: boolean }; +export type GetMemberCountByWorkspaceIdQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type GetMemberCountByWorkspaceIdQuery = { + __typename?: 'Query'; + workspace: { __typename?: 'WorkspaceType'; memberCount: number }; +}; + export type GetMembersByWorkspaceIdQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; + skip: Scalars['Int']['input']; + take: Scalars['Int']['input']; }>; export type GetMembersByWorkspaceIdQuery = { @@ -493,6 +504,11 @@ export type Queries = variables: GetIsOwnerQueryVariables; response: GetIsOwnerQuery; } + | { + name: 'getMemberCountByWorkspaceIdQuery'; + variables: GetMemberCountByWorkspaceIdQueryVariables; + response: GetMemberCountByWorkspaceIdQuery; + } | { name: 'getMembersByWorkspaceIdQuery'; variables: GetMembersByWorkspaceIdQueryVariables; diff --git a/packages/i18n/src/resources/en.json b/packages/i18n/src/resources/en.json index 757aa81874..3c770854b5 100644 --- a/packages/i18n/src/resources/en.json +++ b/packages/i18n/src/resources/en.json @@ -570,5 +570,6 @@ "Workspace Settings with name": "{{name}}'s Settings", "Workspace Type": "Workspace Type", "You cannot delete the last workspace": "You cannot delete the last workspace", + "Removed successfully": "Removed successfully", "Successfully enabled AFFiNE Cloud": "Successfully enabled AFFiNE Cloud" } diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index 607f541c00..2b2a4e7df1 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -10,7 +10,11 @@ import { getBlockSuiteEditorTitle, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; -import { clickUserInfoCard } from '@affine-test/kit/utils/setting'; +import { + clickUserInfoCard, + openSettingModal, + openWorkspaceSettingPanel, +} from '@affine-test/kit/utils/setting'; import { clickSideBarAllPageButton, clickSideBarSettingButton, @@ -188,3 +192,65 @@ test.describe('collaboration', () => { expect(page.url()).toBe(url); }); }); + +test.describe('collaboration members', () => { + test('should have pagination in member list', async ({ page }) => { + await page.reload(); + await waitForEditorLoad(page); + await createLocalWorkspace( + { + name: 'test', + }, + page + ); + await enableCloudWorkspace(page); + await clickNewPageButton(page); + const currentUrl = page.url(); + // format: http://localhost:8080/workspace/${workspaceId}/xxx + const workspaceId = currentUrl.split('/')[4]; + + // create 10 user and add to workspace + const createUserAndAddToWorkspace = async () => { + const userB = await createRandomUser(); + await addUserToWorkspace(workspaceId, userB.id, 1 /* READ */); + }; + await Promise.all( + new Array(10).fill(1).map(() => createUserAndAddToWorkspace()) + ); + + await openSettingModal(page); + await openWorkspaceSettingPanel(page, 'test'); + + await page.waitForTimeout(100); + + const firstPageMemberItemCount = await page + .locator('[data-testid="member-item"]') + .count(); + + expect(firstPageMemberItemCount).toBe(8); + + const navigationItems = await page + .getByRole('navigation') + .getByRole('button') + .all(); + + // There have four pagination items: < 1 2 > + expect(navigationItems.length).toBe(4); + // Click second page + await navigationItems[2].click(); + await page.waitForTimeout(500); + // There should have other three members in second page + const secondPageMemberItemCount = await page + .locator('[data-testid="member-item"]') + .count(); + expect(secondPageMemberItemCount).toBe(3); + // Click left arrow to back to first page + await navigationItems[0].click(); + await page.waitForTimeout(500); + expect(await page.locator('[data-testid="member-item"]').count()).toBe(8); + // Click right arrow to second page + await navigationItems[3].click(); + await page.waitForTimeout(500); + expect(await page.locator('[data-testid="member-item"]').count()).toBe(3); + }); +}); diff --git a/yarn.lock b/yarn.lock index c7d5823032..d3e19bcf47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -208,6 +208,7 @@ __metadata: react-dom: 18.2.0 react-error-boundary: ^4.0.11 react-is: ^18.2.0 + react-paginate: ^8.2.0 rxjs: ^7.8.1 typescript: ^5.2.2 vite: ^4.4.9 @@ -29041,7 +29042,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -29594,6 +29595,17 @@ __metadata: languageName: node linkType: hard +"react-paginate@npm:^8.2.0": + version: 8.2.0 + resolution: "react-paginate@npm:8.2.0" + dependencies: + prop-types: ^15 + peerDependencies: + react: ^16 || ^17 || ^18 + checksum: a0969b4ef27be466ef3e052e2c7c61fca60af24e0d10c76770ebd9953ad6131206a276dfc335e61cf35aa494ff3aacc2610682e3192d7f2ee9541928edb46721 + languageName: node + linkType: hard + "react-popper@npm:^2.2.5, react-popper@npm:^2.3.0": version: 2.3.0 resolution: "react-popper@npm:2.3.0"