feat: support pagination for member list (#4231)

This commit is contained in:
Qi 2023-09-12 11:37:59 +08:00 committed by GitHub
parent 9fe9efe465
commit 98429bf89e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 404 additions and 108 deletions

View File

@ -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<PaginationProps['onPageChange']>(offset => {
setMemberSkip(offset);
}, []);
const onInviteConfirm = useCallback<InviteModalProps['onConfirm']>(
async ({ email, permission }) => {
const success = await invite(
@ -103,11 +96,25 @@ export const CloudWorkspaceMembersPanel = ({
[invite, pushNotification, t]
);
const onRevoke = useCallback<OnRevoke>(
async memberId => {
const res = await revokeMemberPermission(memberId);
if (res?.revoke) {
pushNotification({
title: t['Removed successfully'](),
type: 'success',
});
}
},
[pushNotification, revokeMemberPermission, t]
);
return (
<>
<SettingRow
name={`${t['Members']()} (${memberCount})`}
desc={t['Members hint']()}
spreadCol={isOwner}
>
{isOwner ? (
<>
@ -121,21 +128,78 @@ export const CloudWorkspaceMembersPanel = ({
</>
) : null}
</SettingRow>
<div className={style.membersList}>
{memberList.map(member => (
<MemberItem
key={member.id}
member={member}
<div className={style.membersPanel}>
<Suspense fallback={<MemberListFallback memberCount={memberCount} />}>
<MemberList
workspaceId={workspaceId}
isOwner={isOwner}
currentUser={currentUser}
onRevoke={revokeMemberPermission}
skip={memberSkip}
onRevoke={onRevoke}
/>
))}
</Suspense>
<Pagination
totalCount={memberCount}
countPerPage={COUNT_PER_PAGE}
onPageChange={onPageChange}
/>
</div>
</>
);
};
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 (
<div
style={{
height,
}}
className={style.membersFallback}
>
<Loading size={40} />
</div>
);
};
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 => (
<MemberItem
key={member.id}
member={member}
isOwner={isOwner}
currentUser={currentUser}
onRevoke={onRevoke}
/>
))}
</>
);
};
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 (
<>
<div key={member.id} className={style.listItem}>
<div key={member.id} className={style.listItem} data-testid="member-item">
<Avatar
size={36}
url={member.avatarUrl}
@ -198,6 +262,7 @@ const MemberItem = ({
>
<IconButton
disabled={!operationButtonInfo.show}
type="plain"
style={{
visibility: operationButtonInfo.show ? 'visible' : 'hidden',
flexShrink: 0,

View File

@ -86,13 +86,18 @@ export const fakeWrapper = style({
},
});
export const membersList = style({
export const membersFallback = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: 'var(--affine-primary-color)',
});
export const membersPanel = style({
marginTop: '24px',
padding: '4px',
borderRadius: '12px',
background: 'var(--affine-background-primary-color)',
maxHeight: '464px',
overflow: 'auto',
border: '1px solid var(--affine-border-color)',
});
export const listItem = style({
@ -101,9 +106,14 @@ export const listItem = style({
display: 'flex',
width: '100%',
alignItems: 'center',
':hover': {
background: 'var(--affine-hover-color)',
borderRadius: '8px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
borderRadius: '8px',
},
'&:not(:last-of-type)': {
marginBottom: '6px',
},
},
});
export const memberContainer = style({

View File

@ -0,0 +1,13 @@
import { getMemberCountByWorkspaceIdQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export function useMemberCount(workspaceId: string) {
const { data } = useQuery({
query: getMemberCountByWorkspaceIdQuery,
variables: {
workspaceId,
},
});
return data.workspace.memberCount;
}

View File

@ -8,11 +8,17 @@ export type Member = Omit<
GetMembersByWorkspaceIdQuery['workspace']['members'][number],
'__typename'
>;
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;

View File

@ -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]
);

View File

@ -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 }) => ({

View File

@ -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!

View File

@ -119,7 +119,9 @@ export async function getWorkspaceSharedPages(
async function getWorkspace(
app: INestApplication,
token: string,
workspaceId: string
workspaceId: string,
skip = 0,
take = 8
): Promise<WorkspaceType> {
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 }
}
}
`,

View File

@ -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');
});

View File

@ -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": {

View File

@ -1,2 +1,3 @@
export * from './accept-invite-page';
export * from './invite-modal';
export * from './pagination';

View File

@ -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 (
// <Menu
// trigger="click"
// content={
// <>
// {Object.entries(Permission).map(([permission]) => {
// return (
// <MenuItem
// key={permission}
// onClick={() => {
// onChange(permission as Permission);
// }}
// >
// {permission}
// </MenuItem>
// );
// })}
// </>
// }
// >
// <MenuTrigger
// type="plain"
// style={{
// marginRight: -10,
// height: '100%',
// }}
// >
// {currentPermission}
// </MenuTrigger>
// </Menu>
// );
};
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={
<PermissionMenu
currentPermission={permission}
onChange={setPermission}
/>
}
/>
</div>
<div className={styles.inviteModalButtonContainer}>

View File

@ -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 (
<ReactPaginate
onPageChange={handlePageClick}
pageRangeDisplayed={3}
marginPagesDisplayed={2}
pageCount={pageCount}
previousLabel={<ArrowLeftSmallIcon />}
nextLabel={<ArrowRightSmallIcon />}
pageClassName={styles.pageItem}
previousClassName={clsx(styles.pageItem, 'label')}
nextClassName={clsx(styles.pageItem, 'label')}
breakLabel="..."
breakClassName={styles.pageItem}
containerClassName={styles.pagination}
activeClassName="active"
renderOnZeroPageCount={null}
/>
);
};

View File

@ -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',
});

View File

@ -0,0 +1,5 @@
query getMemberCountByWorkspaceId($workspaceId: String!) {
workspace(id: $workspaceId) {
memberCount
}
}

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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"
}

View File

@ -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);
});
});

View File

@ -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"