mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
fix(admin): unable to select all users in current page (#11155)
close AF-2380
This commit is contained in:
@@ -3,15 +3,20 @@ import {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import type { UserType } from '@affine/graphql';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import { AccountIcon, LockIcon, UnlockIcon } from '@blocksuite/icons/rc';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { MailIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { Checkbox } from '../../../components/ui/checkbox';
|
||||
import type { UserType } from '../schema';
|
||||
import { DataTableColumnHeader } from './data-table-column-header';
|
||||
import { DataTableRowActions } from './data-table-row-actions';
|
||||
|
||||
@@ -47,139 +52,190 @@ const StatusItem = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export const useColumns = ({
|
||||
setSelectedUserIds,
|
||||
}: {
|
||||
setSelectedUserIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
}) => {
|
||||
const columns: ColumnDef<UserType>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={value => {
|
||||
if (value) {
|
||||
setSelectedUserIds(
|
||||
prev =>
|
||||
new Set([
|
||||
...prev,
|
||||
...table
|
||||
.getFilteredRowModel()
|
||||
.rows.map(row => row.original.id),
|
||||
])
|
||||
);
|
||||
} else {
|
||||
// remove selected users in the current page
|
||||
setSelectedUserIds(
|
||||
prev =>
|
||||
new Set(
|
||||
[...prev].filter(
|
||||
id =>
|
||||
!table
|
||||
.getFilteredRowModel()
|
||||
.rows.some(row => row.original.id === id)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<UserType>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={value => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'info',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader className="text-xs" column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-4 items-center max-w-[50vw] overflow-hidden">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={row.original.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<AccountIcon fontSize={20} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1 max-w-full overflow-hidden">
|
||||
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
|
||||
<span>{row.original.name}</span>
|
||||
{row.original.features.includes(FeatureType.Admin) && (
|
||||
<span
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border text-center inline-flex items-center font-normal"
|
||||
style={{
|
||||
borderRadius: '4px',
|
||||
backgroundColor: cssVarV2('chip/label/blue'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
{row.original.disabled && (
|
||||
<span
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border"
|
||||
style={{
|
||||
borderRadius: '4px',
|
||||
backgroundColor: cssVarV2('chip/label/white'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-medium max-w-full overflow-hidden"
|
||||
style={{
|
||||
color: cssVarV2('text/secondary'),
|
||||
table.toggleAllPageRowsSelected(!!value);
|
||||
}}
|
||||
>
|
||||
{row.original.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'property',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs max-md:hidden"
|
||||
column={column}
|
||||
title="UUID"
|
||||
/>
|
||||
),
|
||||
cell: ({ row: { original: user } }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 text-xs max-md:hidden">
|
||||
<div className="flex justify-start">{user.id}</div>
|
||||
<div className="flex gap-3 items-center justify-start">
|
||||
<StatusItem
|
||||
condition={user.hasPassword}
|
||||
IconTrue={
|
||||
<LockIcon
|
||||
fontSize={16}
|
||||
color={cssVarV2('selfhost/icon/tertiary')}
|
||||
/>
|
||||
aria-label="Select all"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={value => {
|
||||
if (value) {
|
||||
setSelectedUserIds(prev => new Set([...prev, row.original.id]));
|
||||
} else {
|
||||
setSelectedUserIds(
|
||||
prev =>
|
||||
new Set([...prev].filter(id => id !== row.original.id))
|
||||
);
|
||||
}
|
||||
IconFalse={<UnlockIcon fontSize={16} />}
|
||||
textTrue="Password Set"
|
||||
textFalse="No Password"
|
||||
/>
|
||||
<StatusItem
|
||||
condition={user.emailVerified}
|
||||
IconTrue={
|
||||
<MailIcon
|
||||
size={16}
|
||||
color={cssVarV2('selfhost/icon/tertiary')}
|
||||
/>
|
||||
}
|
||||
IconFalse={<MailIcon size={16} />}
|
||||
textTrue="Email Verified"
|
||||
textFalse="Email Not Verified"
|
||||
/>
|
||||
row.toggleSelected(!!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'info',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs"
|
||||
column={column}
|
||||
title="Name"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-4 items-center max-w-[50vw] overflow-hidden">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={row.original.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<AccountIcon fontSize={20} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1 max-w-full overflow-hidden">
|
||||
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
|
||||
<span>{row.original.name}</span>
|
||||
{row.original.features.includes(FeatureType.Admin) && (
|
||||
<span
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border text-center inline-flex items-center font-normal"
|
||||
style={{
|
||||
borderRadius: '4px',
|
||||
backgroundColor: cssVarV2('chip/label/blue'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
{row.original.disabled && (
|
||||
<span
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border"
|
||||
style={{
|
||||
borderRadius: '4px',
|
||||
backgroundColor: cssVarV2('chip/label/white'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-medium max-w-full overflow-hidden"
|
||||
style={{
|
||||
color: cssVarV2('text/secondary'),
|
||||
}}
|
||||
>
|
||||
{row.original.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs"
|
||||
column={column}
|
||||
title="Actions"
|
||||
/>
|
||||
),
|
||||
cell: ({ row: { original: user } }) => <DataTableRowActions user={user} />,
|
||||
},
|
||||
];
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'property',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs max-md:hidden"
|
||||
column={column}
|
||||
title="UUID"
|
||||
/>
|
||||
),
|
||||
cell: ({ row: { original: user } }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 text-xs max-md:hidden">
|
||||
<div className="flex justify-start">{user.id}</div>
|
||||
<div className="flex gap-3 items-center justify-start">
|
||||
<StatusItem
|
||||
condition={user.hasPassword}
|
||||
IconTrue={
|
||||
<LockIcon
|
||||
fontSize={16}
|
||||
color={cssVarV2('selfhost/icon/tertiary')}
|
||||
/>
|
||||
}
|
||||
IconFalse={<UnlockIcon fontSize={16} />}
|
||||
textTrue="Password Set"
|
||||
textFalse="No Password"
|
||||
/>
|
||||
<StatusItem
|
||||
condition={user.emailVerified}
|
||||
IconTrue={
|
||||
<MailIcon
|
||||
size={16}
|
||||
color={cssVarV2('selfhost/icon/tertiary')}
|
||||
/>
|
||||
}
|
||||
IconFalse={<MailIcon size={16} />}
|
||||
textTrue="Email Verified"
|
||||
textFalse="Email Not Verified"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs"
|
||||
column={column}
|
||||
title="Actions"
|
||||
/>
|
||||
),
|
||||
cell: ({ row: { original: user } }) => (
|
||||
<DataTableRowActions user={user} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [setSelectedUserIds]);
|
||||
return columns;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import { getUserByEmailQuery, type UserType } from '@affine/graphql';
|
||||
import { getUserByEmailQuery } from '@affine/graphql';
|
||||
import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { useRightPanel } from '../../panel/context';
|
||||
import type { UserType } from '../schema';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { ExportUsersDialog } from './export-users-dialog';
|
||||
import { ImportUsersDialog } from './import-users-dialog';
|
||||
@@ -22,6 +23,7 @@ import { CreateUserForm } from './user-form';
|
||||
interface DataTableToolbarProps<TData> {
|
||||
data: TData[];
|
||||
setDataTable: (data: TData[]) => void;
|
||||
selectedUsers: UserType[];
|
||||
table?: Table<TData>;
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
data,
|
||||
selectedUsers,
|
||||
setDataTable,
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
@@ -158,9 +161,7 @@ export function DataTableToolbar<TData>({
|
||||
|
||||
{table && (
|
||||
<ExportUsersDialog
|
||||
users={table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map(row => row.original as UserType)}
|
||||
users={selectedUsers}
|
||||
open={exportDialogOpen}
|
||||
onOpenChange={setExportDialogOpen}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import type { UserType } from '../schema';
|
||||
import { DataTablePagination } from './data-table-pagination';
|
||||
import { DataTableToolbar } from './data-table-toolbar';
|
||||
import { useUserCount } from './use-user-management';
|
||||
@@ -26,6 +27,7 @@ interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pagination: PaginationState;
|
||||
selectedUsers: UserType[];
|
||||
onPaginationChange: Dispatch<
|
||||
SetStateAction<{
|
||||
pageIndex: number;
|
||||
@@ -34,10 +36,11 @@ interface DataTableProps<TData, TValue> {
|
||||
>;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
export function DataTable<TData extends { id: string }, TValue>({
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
selectedUsers,
|
||||
onPaginationChange,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const usersCount = useUserCount();
|
||||
@@ -50,6 +53,7 @@ export function DataTable<TData, TValue>({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: row => row.id,
|
||||
manualPagination: true,
|
||||
rowCount: usersCount,
|
||||
enableFilters: true,
|
||||
@@ -70,7 +74,12 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto">
|
||||
<DataTableToolbar setDataTable={setTableData} data={data} table={table} />
|
||||
<DataTableToolbar
|
||||
setDataTable={setTableData}
|
||||
data={data}
|
||||
table={table}
|
||||
selectedUsers={selectedUsers}
|
||||
/>
|
||||
<div className="rounded-md border h-full flex flex-col overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import type { UserType } from '@affine/graphql';
|
||||
import { CopyIcon } from '@blocksuite/icons/rc';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { UserType } from '../schema';
|
||||
import { type ExportField, useExportUsers } from './use-user-management';
|
||||
|
||||
interface ExportUsersDialogProps {
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { columns } from './components/columns';
|
||||
import { useColumns } from './components/columns';
|
||||
import { DataTable } from './components/data-table';
|
||||
import type { UserType } from './schema';
|
||||
import { useUserList } from './use-user-list';
|
||||
|
||||
export function AccountPage() {
|
||||
const { users, pagination, setPagination } = useUserList();
|
||||
// Remember the user temporarily, because userList is paginated on the server side,can't get all users at once.
|
||||
const [memoUsers, setMemoUsers] = useState<UserType[]>([]);
|
||||
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
||||
new Set<string>()
|
||||
);
|
||||
const columns = useColumns({ setSelectedUserIds });
|
||||
|
||||
useEffect(() => {
|
||||
setMemoUsers(prev => [...new Set([...prev, ...users])]);
|
||||
}, [users]);
|
||||
|
||||
const selectedUsers = useMemo(() => {
|
||||
return memoUsers.filter(user => selectedUserIds.has(user.id));
|
||||
}, [selectedUserIds, memoUsers]);
|
||||
|
||||
return (
|
||||
<div className=" h-screen flex-1 flex-col flex">
|
||||
<Header title="Accounts" />
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
// @ts-expect-error do not complains
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
selectedUsers={selectedUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user