mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-27 16:21:58 +03:00
feat: support pagination for member list (#4231)
This commit is contained in:
parent
9fe9efe465
commit
98429bf89e
@ -2,6 +2,10 @@ import {
|
|||||||
InviteModal,
|
InviteModal,
|
||||||
type InviteModalProps,
|
type InviteModalProps,
|
||||||
} from '@affine/component/member-components';
|
} from '@affine/component/member-components';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
type PaginationProps,
|
||||||
|
} from '@affine/component/member-components';
|
||||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||||
import { SettingRow } from '@affine/component/setting-components';
|
import { SettingRow } from '@affine/component/setting-components';
|
||||||
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
|
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
|
||||||
@ -11,10 +15,11 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|||||||
import { MoreVerticalIcon } from '@blocksuite/icons';
|
import { MoreVerticalIcon } from '@blocksuite/icons';
|
||||||
import { Avatar } from '@toeverything/components/avatar';
|
import { Avatar } from '@toeverything/components/avatar';
|
||||||
import { Button, IconButton } from '@toeverything/components/button';
|
import { Button, IconButton } from '@toeverything/components/button';
|
||||||
|
import { Loading } from '@toeverything/components/loading';
|
||||||
import { Menu, MenuItem } from '@toeverything/components/menu';
|
import { Menu, MenuItem } from '@toeverything/components/menu';
|
||||||
import { Tooltip } from '@toeverything/components/tooltip';
|
import { Tooltip } from '@toeverything/components/tooltip';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useSetAtom } from 'jotai/react';
|
import { useSetAtom } from 'jotai';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
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 type { CheckedUser } from '../../../hooks/affine/use-current-user';
|
||||||
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
|
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
|
||||||
import { useInviteMember } from '../../../hooks/affine/use-invite-member';
|
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 { type Member, useMembers } from '../../../hooks/affine/use-members';
|
||||||
import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission';
|
import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission';
|
||||||
import { AnyErrorBoundary } from '../any-error-boundary';
|
import { AnyErrorBoundary } from '../any-error-boundary';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import type { WorkspaceSettingDetailProps } from './types';
|
import type { WorkspaceSettingDetailProps } from './types';
|
||||||
|
|
||||||
|
const COUNT_PER_PAGE = 8;
|
||||||
export interface MembersPanelProps extends WorkspaceSettingDetailProps {
|
export interface MembersPanelProps extends WorkspaceSettingDetailProps {
|
||||||
workspace: AffineOfficialWorkspace;
|
workspace: AffineOfficialWorkspace;
|
||||||
}
|
}
|
||||||
|
type OnRevoke = (memberId: string) => void;
|
||||||
const MembersPanelLocal = () => {
|
const MembersPanelLocal = () => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
return (
|
return (
|
||||||
@ -48,41 +55,27 @@ const MembersPanelLocal = () => {
|
|||||||
export const CloudWorkspaceMembersPanel = ({
|
export const CloudWorkspaceMembersPanel = ({
|
||||||
workspace,
|
workspace,
|
||||||
isOwner,
|
isOwner,
|
||||||
}: MembersPanelProps): ReactElement => {
|
}: MembersPanelProps) => {
|
||||||
const workspaceId = workspace.id;
|
const workspaceId = workspace.id;
|
||||||
const members = useMembers(workspaceId);
|
const memberCount = useMemberCount(workspaceId);
|
||||||
|
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const currentUser = useCurrentUser();
|
|
||||||
const { invite, isMutating } = useInviteMember(workspaceId);
|
const { invite, isMutating } = useInviteMember(workspaceId);
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
|
||||||
const revokeMemberPermission = useRevokeMemberPermission(workspaceId);
|
const revokeMemberPermission = useRevokeMemberPermission(workspaceId);
|
||||||
|
|
||||||
const memberCount = members.length;
|
const [open, setOpen] = useState(false);
|
||||||
const memberList = useMemo(
|
const [memberSkip, setMemberSkip] = useState(0);
|
||||||
() =>
|
|
||||||
members.sort((a, b) => {
|
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||||
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 openModal = useCallback(() => {
|
const openModal = useCallback(() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onPageChange = useCallback<PaginationProps['onPageChange']>(offset => {
|
||||||
|
setMemberSkip(offset);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onInviteConfirm = useCallback<InviteModalProps['onConfirm']>(
|
const onInviteConfirm = useCallback<InviteModalProps['onConfirm']>(
|
||||||
async ({ email, permission }) => {
|
async ({ email, permission }) => {
|
||||||
const success = await invite(
|
const success = await invite(
|
||||||
@ -103,11 +96,25 @@ export const CloudWorkspaceMembersPanel = ({
|
|||||||
[invite, pushNotification, t]
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
name={`${t['Members']()} (${memberCount})`}
|
name={`${t['Members']()} (${memberCount})`}
|
||||||
desc={t['Members hint']()}
|
desc={t['Members hint']()}
|
||||||
|
spreadCol={isOwner}
|
||||||
>
|
>
|
||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
<>
|
<>
|
||||||
@ -121,21 +128,78 @@ export const CloudWorkspaceMembersPanel = ({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<div className={style.membersList}>
|
|
||||||
{memberList.map(member => (
|
<div className={style.membersPanel}>
|
||||||
<MemberItem
|
<Suspense fallback={<MemberListFallback memberCount={memberCount} />}>
|
||||||
key={member.id}
|
<MemberList
|
||||||
member={member}
|
workspaceId={workspaceId}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
currentUser={currentUser}
|
skip={memberSkip}
|
||||||
onRevoke={revokeMemberPermission}
|
onRevoke={onRevoke}
|
||||||
/>
|
/>
|
||||||
))}
|
</Suspense>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
totalCount={memberCount}
|
||||||
|
countPerPage={COUNT_PER_PAGE}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
</div>
|
</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 = ({
|
const MemberItem = ({
|
||||||
member,
|
member,
|
||||||
isOwner,
|
isOwner,
|
||||||
@ -145,7 +209,7 @@ const MemberItem = ({
|
|||||||
member: Member;
|
member: Member;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
currentUser: CheckedUser;
|
currentUser: CheckedUser;
|
||||||
onRevoke: (memberId: string) => void;
|
onRevoke: OnRevoke;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
@ -162,7 +226,7 @@ const MemberItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div key={member.id} className={style.listItem}>
|
<div key={member.id} className={style.listItem} data-testid="member-item">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={36}
|
size={36}
|
||||||
url={member.avatarUrl}
|
url={member.avatarUrl}
|
||||||
@ -198,6 +262,7 @@ const MemberItem = ({
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!operationButtonInfo.show}
|
disabled={!operationButtonInfo.show}
|
||||||
|
type="plain"
|
||||||
style={{
|
style={{
|
||||||
visibility: operationButtonInfo.show ? 'visible' : 'hidden',
|
visibility: operationButtonInfo.show ? 'visible' : 'hidden',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
@ -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',
|
marginTop: '24px',
|
||||||
padding: '4px',
|
padding: '4px',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
background: 'var(--affine-background-primary-color)',
|
background: 'var(--affine-background-primary-color)',
|
||||||
maxHeight: '464px',
|
border: '1px solid var(--affine-border-color)',
|
||||||
overflow: 'auto',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listItem = style({
|
export const listItem = style({
|
||||||
@ -101,9 +106,14 @@ export const listItem = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
':hover': {
|
selectors: {
|
||||||
background: 'var(--affine-hover-color)',
|
'&:hover': {
|
||||||
borderRadius: '8px',
|
background: 'var(--affine-hover-color)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
},
|
||||||
|
'&:not(:last-of-type)': {
|
||||||
|
marginBottom: '6px',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const memberContainer = style({
|
export const memberContainer = style({
|
||||||
|
13
apps/core/src/hooks/affine/use-member-count.ts
Normal file
13
apps/core/src/hooks/affine/use-member-count.ts
Normal 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;
|
||||||
|
}
|
@ -8,11 +8,17 @@ export type Member = Omit<
|
|||||||
GetMembersByWorkspaceIdQuery['workspace']['members'][number],
|
GetMembersByWorkspaceIdQuery['workspace']['members'][number],
|
||||||
'__typename'
|
'__typename'
|
||||||
>;
|
>;
|
||||||
export function useMembers(workspaceId: string) {
|
export function useMembers(
|
||||||
|
workspaceId: string,
|
||||||
|
skip: number,
|
||||||
|
take: number = 8
|
||||||
|
) {
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
query: getMembersByWorkspaceIdQuery,
|
query: getMembersByWorkspaceIdQuery,
|
||||||
variables: {
|
variables: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return data.workspace.members;
|
return data.workspace.members;
|
||||||
|
@ -12,11 +12,12 @@ export function useRevokeMemberPermission(workspaceId: string) {
|
|||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (userId: string) => {
|
async (userId: string) => {
|
||||||
await trigger({
|
const res = await trigger({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
await mutate();
|
await mutate();
|
||||||
|
return res;
|
||||||
},
|
},
|
||||||
[mutate, trigger, workspaceId]
|
[mutate, trigger, workspaceId]
|
||||||
);
|
);
|
||||||
|
@ -177,7 +177,6 @@ export class WorkspaceResolver {
|
|||||||
return this.prisma.userWorkspacePermission.count({
|
return this.prisma.userWorkspacePermission.count({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
accepted: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -210,15 +209,25 @@ export class WorkspaceResolver {
|
|||||||
description: 'Members of workspace',
|
description: 'Members of workspace',
|
||||||
complexity: 2,
|
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({
|
const data = await this.prisma.userWorkspacePermission.findMany({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
},
|
},
|
||||||
|
skip,
|
||||||
|
take: take || 8,
|
||||||
|
orderBy: {
|
||||||
|
type: 'desc',
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
user: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data
|
return data
|
||||||
.filter(({ user }) => !!user)
|
.filter(({ user }) => !!user)
|
||||||
.map(({ id, accepted, type, user }) => ({
|
.map(({ id, accepted, type, user }) => ({
|
||||||
|
@ -98,7 +98,7 @@ type WorkspaceType {
|
|||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
|
|
||||||
"""Members of workspace"""
|
"""Members of workspace"""
|
||||||
members: [InviteUserType!]!
|
members(skip: Int, take: Int): [InviteUserType!]!
|
||||||
|
|
||||||
"""Permission of current signed in user in workspace"""
|
"""Permission of current signed in user in workspace"""
|
||||||
permission: Permission!
|
permission: Permission!
|
||||||
|
@ -119,7 +119,9 @@ export async function getWorkspaceSharedPages(
|
|||||||
async function getWorkspace(
|
async function getWorkspace(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
token: string,
|
token: string,
|
||||||
workspaceId: string
|
workspaceId: string,
|
||||||
|
skip = 0,
|
||||||
|
take = 8
|
||||||
): Promise<WorkspaceType> {
|
): Promise<WorkspaceType> {
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post(gql)
|
.post(gql)
|
||||||
@ -129,7 +131,7 @@ async function getWorkspace(
|
|||||||
query: `
|
query: `
|
||||||
query {
|
query {
|
||||||
workspace(id: "${workspaceId}") {
|
workspace(id: "${workspaceId}") {
|
||||||
id, members { id, name, email, permission, inviteId }
|
id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
@ -247,3 +247,34 @@ test('should send email', async t => {
|
|||||||
}
|
}
|
||||||
t.pass();
|
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');
|
||||||
|
});
|
||||||
|
@ -49,6 +49,7 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "^4.0.11",
|
"react-error-boundary": "^4.0.11",
|
||||||
"react-is": "^18.2.0",
|
"react-is": "^18.2.0",
|
||||||
|
"react-paginate": "^8.2.0",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from './accept-invite-page';
|
export * from './accept-invite-page';
|
||||||
export * from './invite-modal';
|
export * from './invite-modal';
|
||||||
|
export * from './pagination';
|
||||||
|
@ -15,50 +15,6 @@ export interface InviteModalProps {
|
|||||||
isMutating: boolean;
|
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 = ({
|
export const InviteModal = ({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
@ -67,7 +23,7 @@ export const InviteModal = ({
|
|||||||
}: InviteModalProps) => {
|
}: InviteModalProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
const [permission, setPermission] = useState(Permission.Write);
|
const [permission] = useState(Permission.Write);
|
||||||
const [isValidEmail, setIsValidEmail] = useState(true);
|
const [isValidEmail, setIsValidEmail] = useState(true);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
@ -125,12 +81,6 @@ export const InviteModal = ({
|
|||||||
onEnter={handleConfirm}
|
onEnter={handleConfirm}
|
||||||
style={{ marginTop: 20 }}
|
style={{ marginTop: 20 }}
|
||||||
size="large"
|
size="large"
|
||||||
endFix={
|
|
||||||
<PermissionMenu
|
|
||||||
currentPermission={permission}
|
|
||||||
onChange={setPermission}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.inviteModalButtonContainer}>
|
<div className={styles.inviteModalButtonContainer}>
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const inviteModalTitle = style({
|
export const inviteModalTitle = style({
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@ -21,3 +21,48 @@ export const inviteName = style({
|
|||||||
marginRight: '10px',
|
marginRight: '10px',
|
||||||
color: 'var(--affine-black)',
|
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',
|
||||||
|
});
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
query getMemberCountByWorkspaceId($workspaceId: String!) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
memberCount
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
query getMembersByWorkspaceId($workspaceId: String!) {
|
query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
|
||||||
workspace(id: $workspaceId) {
|
workspace(id: $workspaceId) {
|
||||||
members {
|
members(skip: $skip, take: $take) {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
email
|
email
|
||||||
|
@ -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 = {
|
export const getMembersByWorkspaceIdQuery = {
|
||||||
id: 'getMembersByWorkspaceIdQuery' as const,
|
id: 'getMembersByWorkspaceIdQuery' as const,
|
||||||
operationName: 'getMembersByWorkspaceId',
|
operationName: 'getMembersByWorkspaceId',
|
||||||
definitionName: 'workspace',
|
definitionName: 'workspace',
|
||||||
containsFile: false,
|
containsFile: false,
|
||||||
query: `
|
query: `
|
||||||
query getMembersByWorkspaceId($workspaceId: String!) {
|
query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
|
||||||
workspace(id: $workspaceId) {
|
workspace(id: $workspaceId) {
|
||||||
members {
|
members(skip: $skip, take: $take) {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
email
|
email
|
||||||
|
@ -206,8 +206,19 @@ export type GetIsOwnerQueryVariables = Exact<{
|
|||||||
|
|
||||||
export type GetIsOwnerQuery = { __typename?: 'Query'; isOwner: boolean };
|
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<{
|
export type GetMembersByWorkspaceIdQueryVariables = Exact<{
|
||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
|
skip: Scalars['Int']['input'];
|
||||||
|
take: Scalars['Int']['input'];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type GetMembersByWorkspaceIdQuery = {
|
export type GetMembersByWorkspaceIdQuery = {
|
||||||
@ -493,6 +504,11 @@ export type Queries =
|
|||||||
variables: GetIsOwnerQueryVariables;
|
variables: GetIsOwnerQueryVariables;
|
||||||
response: GetIsOwnerQuery;
|
response: GetIsOwnerQuery;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'getMemberCountByWorkspaceIdQuery';
|
||||||
|
variables: GetMemberCountByWorkspaceIdQueryVariables;
|
||||||
|
response: GetMemberCountByWorkspaceIdQuery;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'getMembersByWorkspaceIdQuery';
|
name: 'getMembersByWorkspaceIdQuery';
|
||||||
variables: GetMembersByWorkspaceIdQueryVariables;
|
variables: GetMembersByWorkspaceIdQueryVariables;
|
||||||
|
@ -570,5 +570,6 @@
|
|||||||
"Workspace Settings with name": "{{name}}'s Settings",
|
"Workspace Settings with name": "{{name}}'s Settings",
|
||||||
"Workspace Type": "Workspace Type",
|
"Workspace Type": "Workspace Type",
|
||||||
"You cannot delete the last workspace": "You cannot delete the last workspace",
|
"You cannot delete the last workspace": "You cannot delete the last workspace",
|
||||||
|
"Removed successfully": "Removed successfully",
|
||||||
"Successfully enabled AFFiNE Cloud": "Successfully enabled AFFiNE Cloud"
|
"Successfully enabled AFFiNE Cloud": "Successfully enabled AFFiNE Cloud"
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,11 @@ import {
|
|||||||
getBlockSuiteEditorTitle,
|
getBlockSuiteEditorTitle,
|
||||||
waitForEditorLoad,
|
waitForEditorLoad,
|
||||||
} from '@affine-test/kit/utils/page-logic';
|
} 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 {
|
import {
|
||||||
clickSideBarAllPageButton,
|
clickSideBarAllPageButton,
|
||||||
clickSideBarSettingButton,
|
clickSideBarSettingButton,
|
||||||
@ -188,3 +192,65 @@ test.describe('collaboration', () => {
|
|||||||
expect(page.url()).toBe(url);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
14
yarn.lock
14
yarn.lock
@ -208,6 +208,7 @@ __metadata:
|
|||||||
react-dom: 18.2.0
|
react-dom: 18.2.0
|
||||||
react-error-boundary: ^4.0.11
|
react-error-boundary: ^4.0.11
|
||||||
react-is: ^18.2.0
|
react-is: ^18.2.0
|
||||||
|
react-paginate: ^8.2.0
|
||||||
rxjs: ^7.8.1
|
rxjs: ^7.8.1
|
||||||
typescript: ^5.2.2
|
typescript: ^5.2.2
|
||||||
vite: ^4.4.9
|
vite: ^4.4.9
|
||||||
@ -29041,7 +29042,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 15.8.1
|
||||||
resolution: "prop-types@npm:15.8.1"
|
resolution: "prop-types@npm:15.8.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -29594,6 +29595,17 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"react-popper@npm:^2.2.5, react-popper@npm:^2.3.0":
|
||||||
version: 2.3.0
|
version: 2.3.0
|
||||||
resolution: "react-popper@npm:2.3.0"
|
resolution: "react-popper@npm:2.3.0"
|
||||||
|
Loading…
Reference in New Issue
Block a user