fix(admin): unable to select all users in current page (#11155)

close AF-2380
This commit is contained in:
JimmFly
2025-03-26 10:16:18 +00:00
parent 592f0e8e19
commit eb8fe91525
5 changed files with 228 additions and 143 deletions

View File

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

View File

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

View File

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

View File

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

View File

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