feat(admin): add import and export users to admin panel (#10810)

This commit is contained in:
JimmFly
2025-03-13 18:37:44 +08:00
committed by forehalo
parent e96302ccb2
commit bed4074bdb
13 changed files with 1214 additions and 37 deletions

View File

@@ -17,6 +17,8 @@ import {
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Checkbox } from '../../../components/ui/checkbox';
import { DataTableColumnHeader } from './data-table-column-header';
import { DataTableRowActions } from './data-table-row-actions';
const StatusItem = ({
@@ -54,9 +56,36 @@ const StatusItem = ({
export const columns: ColumnDef<UserType>[] = [
{
accessorKey: 'info',
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 }) => (
<div className="flex gap-3 items-center max-w-[50vw] overflow-hidden">
<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>
@@ -102,6 +131,13 @@ export const columns: ColumnDef<UserType>[] = [
},
{
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">
@@ -124,8 +160,18 @@ export const columns: ColumnDef<UserType>[] = [
/>
</div>
</div>
<DataTableRowActions user={user} />
</div>
),
},
{
id: 'actions',
header: ({ column }) => (
<DataTableColumnHeader
className="text-xs"
column={column}
title="Actions"
/>
),
cell: ({ row: { original: user } }) => <DataTableRowActions user={user} />,
},
];

View File

@@ -0,0 +1,22 @@
import type { Column } from '@tanstack/react-table';
import { cn } from '../../../utils';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
// TODO(@Jimmfly): add sort
return <div className={cn(className)}>{title}</div>;
}

View File

@@ -1,10 +1,11 @@
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 } from '@affine/graphql';
import { PlusIcon } from 'lucide-react';
import type { SetStateAction } from 'react';
import { getUserByEmailQuery, type UserType } from '@affine/graphql';
import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc';
import type { Table } from '@tanstack/react-table';
import {
type SetStateAction,
startTransition,
useCallback,
useEffect,
@@ -14,11 +15,14 @@ import {
import { useRightPanel } from '../../panel/context';
import { DiscardChanges } from './discard-changes';
import { ExportUsersDialog } from './export-users-dialog';
import { ImportUsersDialog } from './import-users-dialog';
import { CreateUserForm } from './user-form';
interface DataTableToolbarProps<TData> {
data: TData[];
setDataTable: (data: TData[]) => void;
table?: Table<TData>;
}
const useSearch = () => {
@@ -55,9 +59,12 @@ function useDebouncedValue<T>(value: T, delay: number): T {
export function DataTableToolbar<TData>({
data,
setDataTable,
table,
}: DataTableToolbarProps<TData>) {
const [value, setValue] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const debouncedValue = useDebouncedValue(value, 1000);
const { setPanelContent, openPanel, closePanel, isOpen } = useRightPanel();
const { result, query } = useSearch();
@@ -106,22 +113,82 @@ export function DataTableToolbar<TData>({
return handleConfirm();
}, [handleConfirm, isOpen]);
const handleExportUsers = useCallback(() => {
if (!table) return;
const selectedRows = table.getFilteredSelectedRowModel().rows;
if (selectedRows.length === 0) {
alert('Please select at least one user to export');
return;
}
setExportDialogOpen(true);
}, [table]);
const handleImportUsers = useCallback(() => {
setImportDialogOpen(true);
}, []);
return (
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Search Email"
value={value}
onChange={onValueChange}
className="h-10 w-full mr-[10px]"
<div className="flex items-center justify-between gap-y-2 gap-x-4">
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="h-8 px-2 lg:px-3"
onClick={handleImportUsers}
>
<ImportIcon fontSize={20} />
<span className="ml-2 hidden md:inline-block">Import</span>
</Button>
<Button
variant="outline"
size="sm"
className="h-8 px-2 lg:px-3"
onClick={handleExportUsers}
disabled={
!table || table.getFilteredSelectedRowModel().rows.length === 0
}
>
<ExportIcon fontSize={20} />
<span className="ml-2 hidden md:inline-block">Export</span>
</Button>
{table && (
<ExportUsersDialog
users={table
.getFilteredSelectedRowModel()
.rows.map(row => row.original as UserType)}
open={exportDialogOpen}
onOpenChange={setExportDialogOpen}
/>
)}
<ImportUsersDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
/>
</div>
<Button
className="px-4 py-2 space-x-[10px] text-sm font-medium"
onClick={handleOpenConfirm}
>
<PlusIcon size={20} /> <span>Add User</span>
</Button>
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
<div className="flex">
<Input
placeholder="Search Email"
value={value}
onChange={onValueChange}
className="h-8 w-[150px] lg:w-[250px]"
/>
</div>
<Button
className="h-8 px-2 lg:px-3 space-x-[6px] text-sm font-medium"
onClick={handleOpenConfirm}
>
<PlusIcon fontSize={20} /> <span>Add User</span>
</Button>
</div>
<DiscardChanges
open={dialogOpen}
onOpenChange={setDialogOpen}

View File

@@ -1,11 +1,16 @@
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
import type {
ColumnDef,
ColumnFiltersState,
PaginationState,
} from '@tanstack/react-table';
import {
flexRender,
getCoreRowModel,
@@ -37,6 +42,9 @@ export function DataTable<TData, TValue>({
}: DataTableProps<TData, TValue>) {
const usersCount = useUserCount();
const [rowSelection, setRowSelection] = useState({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [tableData, setTableData] = useState(data);
const table = useReactTable({
data: tableData,
@@ -46,8 +54,13 @@ export function DataTable<TData, TValue>({
rowCount: usersCount,
enableFilters: true,
onPaginationChange: onPaginationChange,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onColumnFiltersChange: setColumnFilters,
state: {
pagination,
rowSelection,
columnFilters,
},
});
@@ -57,24 +70,72 @@ export function DataTable<TData, TValue>({
return (
<div className="flex flex-col gap-4 py-5 px-6 h-full">
<DataTableToolbar setDataTable={setTableData} data={data} />
<ScrollArea className="rounded-md border max-h-[75vh] h-full">
<DataTableToolbar setDataTable={setTableData} data={data} table={table} />
<div className="rounded-md border max-h-[75vh] h-full overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className="flex items-center">
{headerGroup.headers.map(header => {
let columnClassName = '';
if (header.id === 'select') {
columnClassName = 'w-[40px] flex-shrink-0';
} else if (header.id === 'info') {
columnClassName = 'flex-1';
} else if (header.id === 'property') {
columnClassName = 'flex-1';
} else if (header.id === 'actions') {
columnClassName =
'w-[40px] flex-shrink-0 justify-center mr-6';
}
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={`${columnClassName} py-2 text-xs flex items-center h-9`}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
className="flex items-center justify-between"
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
<TableRow key={row.id} className="flex items-center">
{row.getVisibleCells().map(cell => {
let columnClassName = '';
if (cell.column.id === 'select') {
columnClassName = 'w-[40px] flex-shrink-0';
} else if (cell.column.id === 'info') {
columnClassName = 'flex-1';
} else if (cell.column.id === 'property') {
columnClassName = 'flex-1';
} else if (cell.column.id === 'actions') {
columnClassName =
'w-[40px] flex-shrink-0 justify-center mr-6';
}
return (
<TableCell
key={cell.id}
className={`${columnClassName} flex items-center`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
@@ -89,7 +150,7 @@ export function DataTable<TData, TValue>({
)}
</TableBody>
</Table>
</ScrollArea>
</div>
<DataTablePagination table={table} />
</div>

View File

@@ -0,0 +1,130 @@
import { Button } from '@affine/admin/components/ui/button';
import { Checkbox } from '@affine/admin/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 ExportField, useExportUsers } from './use-user-management';
interface ExportUsersDialogProps {
users: UserType[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ExportUsersDialog({
users,
open,
onOpenChange,
}: ExportUsersDialogProps) {
const [isExporting, setIsExporting] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const [fields, setFields] = useState<ExportField[]>([
{
id: 'name',
label: 'Username',
checked: true,
},
{
id: 'email',
label: 'Email',
checked: true,
},
]);
const handleFieldChange = useCallback(
(id: string, checked: boolean) => {
setFields(
fields.map(field => (field.id === id ? { ...field, checked } : field))
);
},
[fields]
);
const { exportCSV, copyToClipboard } = useExportUsers();
const handleExport = useAsyncCallback(async () => {
setIsExporting(true);
try {
await exportCSV(users, fields, () => {
setIsExporting(false);
onOpenChange(false);
toast('Users exported successfully');
});
} catch (error) {
console.error('Failed to export users', error);
toast.error('Failed to export users');
setIsExporting(false);
}
}, [exportCSV, fields, onOpenChange, users]);
const handleCopy = useAsyncCallback(async () => {
setIsCopying(true);
try {
await copyToClipboard(users, fields, () => {
setIsCopying(false);
onOpenChange(false);
toast('Users copied successfully');
});
} catch (error) {
console.error('Failed to copy users', error);
toast.error('Failed to copy users');
setIsCopying(false);
}
}, [copyToClipboard, fields, onOpenChange, users]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Export</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{fields.map(field => (
<div key={field.id} className="flex items-center space-x-2">
<Checkbox
id={`export-${field.id}`}
checked={field.checked}
onCheckedChange={checked =>
handleFieldChange(field.id, !!checked)
}
/>
<Label htmlFor={`export-${field.id}`}>{field.label}</Label>
</div>
))}
</div>
<DialogFooter>
<Button
type="button"
onClick={handleExport}
className="w-full text-[15px] px-4 py-2 h-10"
disabled={isExporting || isCopying}
>
{isExporting ? 'Exporting...' : 'Download account information'}
</Button>
<Button
variant="outline"
size="icon"
className="p-5"
onClick={handleCopy}
disabled={isExporting || isCopying}
>
<CopyIcon fontSize={20} />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,119 @@
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { UploadIcon } from '@blocksuite/icons/rc';
import {
type ChangeEvent,
type DragEvent,
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { toast } from 'sonner';
interface FileUploadAreaProps {
onFileSelected: (file: File) => Promise<void>;
}
export interface FileUploadAreaRef {
triggerFileUpload: () => void;
}
/**
* Component for CSV file upload with drag and drop support
*/
export const FileUploadArea = forwardRef<
FileUploadAreaRef,
FileUploadAreaProps
>(({ onFileSelected }, ref) => {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = useAsyncCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
await onFileSelected(file);
},
[onFileSelected]
);
const triggerFileInput = () => {
fileInputRef.current?.click();
};
useImperativeHandle(ref, () => ({
triggerFileUpload: triggerFileInput,
}));
const validateAndProcessFile = useAsyncCallback(
async (file: File) => {
if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) {
toast.error('Please upload a CSV file');
return;
}
await onFileSelected(file);
},
[onFileSelected]
);
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length === 0) return;
validateAndProcessFile(files[0]);
},
[validateAndProcessFile]
);
return (
<div
className={`flex justify-center p-8 border-2 border-dashed rounded-md ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={triggerFileInput}
>
<div className="text-center">
<UploadIcon fontSize={36} className="mx-auto mb-2 text-gray-400" />
<div className="text-sm font-medium">
{isDragging
? 'Release mouse to upload file'
: 'Click to upload CSV file'}
</div>
<p className="mt-1 text-xs text-gray-500">
{isDragging ? 'Preparing to upload...' : 'Or drag and drop file here'}
</p>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".csv"
className="hidden"
/>
</div>
);
});
FileUploadArea.displayName = 'FileUploadArea';

View File

@@ -0,0 +1,315 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
downloadCsvTemplate,
exportImportResults,
getValidUsersToImport,
ImportStatus,
type ParsedUser,
processCSVFile,
} from '../utils/csv-utils';
import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area';
import {
useImportUsers,
type UserImportReturnType,
} from './use-user-management';
import { UserTable } from './user-table';
interface ImportUsersDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ImportUsersDialog({
open,
onOpenChange,
}: ImportUsersDialogProps) {
const [isImporting, setIsImporting] = useState(false);
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [isFormatError, setIsFormatError] = useState(false);
const importUsers = useImportUsers();
const fileUploadRef = useRef<FileUploadAreaRef>(null);
const handleUpload = useCallback(
() => fileUploadRef.current?.triggerFileUpload(),
[]
);
// Reset all states when dialog is closed
useEffect(() => {
if (open) {
setIsPreviewMode(false);
setParsedUsers([]);
setIsImporting(false);
setIsFormatError(false);
}
}, [open]);
const importUsersCallback = useCallback(
(result: UserImportReturnType) => {
const successfulUsers = result.filter(
(user): user is Extract<typeof user, { __typename: 'UserType' }> =>
user.__typename === 'UserType'
);
const failedUsers = result.filter(
(
user
): user is Extract<
typeof user,
{ __typename: 'UserImportFailedType' }
> => user.__typename === 'UserImportFailedType'
);
const successCount = successfulUsers.length;
const failedCount = parsedUsers.length - successCount;
if (failedCount > 0) {
toast.info(
`Successfully imported ${successCount} users, ${failedCount} failed`
);
} else {
toast.success(`Successfully imported ${successCount} users`);
}
const successfulUserEmails = new Set(
successfulUsers.map(user => user.email)
);
const failedUserErrorMap = new Map(
failedUsers.map(user => [user.email, user.error])
);
setParsedUsers(prev => {
return prev.map(user => {
if (successfulUserEmails.has(user.email)) {
return {
...user,
importStatus: ImportStatus.Success,
};
}
const errorMessage = failedUserErrorMap.get(user.email) || user.error;
return {
...user,
importStatus: ImportStatus.Failed,
importError: errorMessage,
};
});
});
setIsImporting(false);
},
[parsedUsers.length, setIsImporting]
);
const handleFileSelected = useCallback(async (file: File) => {
setIsImporting(true);
try {
await processCSVFile(
file,
validatedUsers => {
setParsedUsers(validatedUsers);
setIsPreviewMode(true);
setIsImporting(false);
},
() => {
setIsImporting(false);
setIsFormatError(true);
}
);
} catch (error) {
console.error('Failed to process file', error);
setIsImporting(false);
setIsFormatError(true);
}
}, []);
const confirmImport = useAsyncCallback(async () => {
setIsImporting(true);
try {
const validUsersToImport = getValidUsersToImport(parsedUsers);
setParsedUsers(prev =>
prev.map(user =>
user.valid ? { ...user, importStatus: ImportStatus.Processing } : user
)
);
await importUsers({ users: validUsersToImport }, importUsersCallback);
// Note: setIsImporting(false) is now handled in importUsersCallback
} catch (error) {
console.error('Failed to import users', error);
toast.error('Failed to import users');
setIsImporting(false);
}
}, [importUsers, importUsersCallback, parsedUsers]);
const cancelImport = useCallback(() => {
setIsPreviewMode(false);
setParsedUsers([]);
}, []);
const resetFormatError = useCallback(() => {
setIsFormatError(false);
}, []);
// Handle closing the dialog after import is complete
const handleDone = useCallback(() => {
// Reset all states and close the dialog
setIsPreviewMode(false);
setParsedUsers([]);
setIsImporting(false);
setIsFormatError(false);
onOpenChange(false);
}, [onOpenChange]);
// Export failed imports to CSV
const exportResult = useCallback(() => {
exportImportResults(parsedUsers);
}, [parsedUsers]);
const isImported = useMemo(() => {
return parsedUsers.some(
user => user.importStatus && user.importStatus !== ImportStatus.Processing
);
}, [parsedUsers]);
const handleConfirm = useCallback(() => {
if (isImported) {
exportResult();
handleDone();
} else {
confirmImport();
}
}, [confirmImport, exportResult, handleDone, isImported]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={isPreviewMode ? 'sm:max-w-[600px]' : 'sm:max-w-[425px]'}
>
<DialogHeader>
<DialogTitle>
{isFormatError
? 'Incorrect import format'
: isPreviewMode
? isImported
? 'Import results'
: 'Confirm import'
: 'Import'}
</DialogTitle>
</DialogHeader>
{isFormatError ? (
<div className="grid gap-4 py-4">
<p className="text-sm text-gray-500">
You need to import the accounts by importing a CSV file in the
correct format. Please download the CSV template.
</p>
</div>
) : isPreviewMode ? (
<div className="grid gap-4 py-4">
<p className="text-sm text-gray-500">
{parsedUsers.length} users detected from the CSV file. Please
confirm the user list below and import.
</p>
<UserTable users={parsedUsers} />
</div>
) : (
<div className="grid gap-4 py-4">
<p className="text-sm text-gray-500">
You need to import the accounts by importing a CSV file in the
correct format. Please download the CSV template.
</p>
<FileUploadArea
ref={fileUploadRef}
onFileSelected={handleFileSelected}
/>
</div>
)}
<DialogFooter
className={`flex-col sm:flex-row sm:justify-between items-center ${isPreviewMode ? 'sm:justify-end' : 'sm:justify-between'}`}
>
{isFormatError ? (
<>
<div
onClick={downloadCsvTemplate}
className="mb-2 sm:mb-0 text-[15px] px-4 py-2 h-10 underline cursor-pointer"
>
CSV template
</div>
<Button
type="button"
variant="outline"
onClick={resetFormatError}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
>
Done
</Button>
</>
) : isPreviewMode ? (
<>
<Button
variant="outline"
size="sm"
onClick={cancelImport}
className="mb-2 sm:mb-0"
disabled={isImporting}
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
disabled={
isImporting ||
parsedUsers.some(
user => user.importStatus === ImportStatus.Processing
)
}
>
{isImporting
? 'Importing...'
: isImported
? 'Export'
: 'Confirm Import'}
</Button>
</>
) : (
<>
<div
onClick={downloadCsvTemplate}
className="mb-2 sm:mb-0 underline text-[15px] cursor-pointer"
>
CSV template
</div>
<Button
type="button"
onClick={handleUpload}
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
disabled={isImporting}
>
{isImporting ? 'Parsing...' : 'Choose a file'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,6 +11,9 @@ import {
disableUserMutation,
enableUserMutation,
getUsersCountQuery,
type ImportUsersInput,
type ImportUsersMutation,
importUsersMutation,
listUsersQuery,
updateAccountFeaturesMutation,
updateAccountMutation,
@@ -18,7 +21,15 @@ import {
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
import type { UserInput } from '../schema';
import type { UserInput, UserType } from '../schema';
export interface ExportField {
id: string;
label: string;
checked: boolean;
}
export type UserImportReturnType = ImportUsersMutation['importUsers'];
export const useCreateUser = () => {
const {
@@ -221,3 +232,115 @@ export const useUserCount = () => {
});
return usersCount;
};
export const useImportUsers = () => {
const { trigger: importUsers } = useMutation({
mutation: importUsersMutation,
});
const revalidate = useMutateQueryResource();
const handleImportUsers = useCallback(
async (
input: ImportUsersInput,
callback?: (importUsers: UserImportReturnType) => void
) => {
await importUsers({ input })
.then(async ({ importUsers }) => {
await revalidate(listUsersQuery);
callback?.(importUsers);
})
.catch(e => {
toast.error('Failed to import users: ' + e.message);
});
},
[importUsers, revalidate]
);
return handleImportUsers;
};
export const useExportUsers = () => {
const exportCSV = useCallback(
async (users: UserType[], fields: ExportField[], callback?: () => void) => {
const selectedFields = fields
.filter(field => field.checked)
.map(field => field.id);
if (selectedFields.length === 0) {
alert('Please select at least one field to export');
return;
}
const headers = selectedFields.map(
fieldId => fields.find(field => field.id === fieldId)?.label || fieldId
);
const csvRows = [headers.join(',')];
users.forEach(user => {
const row = selectedFields.map(fieldId => {
const value = user[fieldId as keyof UserType];
return typeof value === 'string'
? `"${value.replace(/"/g, '""')}"`
: String(value);
});
csvRows.push(row.join(','));
});
const csvContent = csvRows.join('\n');
// Add BOM (Byte Order Mark) to force Excel to interpret the file as UTF-8
const BOM = '\uFEFF';
const csvContentWithBOM = BOM + csvContent;
const blob = new Blob([csvContentWithBOM], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'exported_users.csv');
link.style.visibility = 'hidden';
document.body.append(link);
link.click();
setTimeout(() => {
link.remove();
URL.revokeObjectURL(url);
}, 100);
callback?.();
},
[]
);
const copyToClipboard = useCallback(
async (users: UserType[], fields: ExportField[], callback?: () => void) => {
const selectedFields = fields
.filter(field => field.checked)
.map(field => field.id);
const dataToCopy: {
[key: string]: string;
}[] = [];
users.forEach(user => {
const row: { [key: string]: string } = {};
selectedFields.forEach(fieldId => {
const value = user[fieldId as keyof UserType];
row[fieldId] = typeof value === 'string' ? value : String(value);
});
dataToCopy.push(row);
});
navigator.clipboard.writeText(JSON.stringify(dataToCopy, null, 2));
callback?.();
},
[]
);
return {
exportCSV,
copyToClipboard,
};
};

View File

@@ -0,0 +1,78 @@
import { ImportStatus, type ParsedUser } from '../utils/csv-utils';
interface UserTableProps {
users: ParsedUser[];
}
/**
* Displays a table of users with their import status
*/
export const UserTable: React.FC<UserTableProps> = ({ users }) => {
return (
<div className="max-h-[300px] overflow-y-auto border rounded-md">
<table className="w-full border-collapse">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username
</th>
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{users.map((user, index) => (
<tr
key={`${user.email}-${index}`}
className={`${user.valid === false ? 'bg-red-50' : ''}
${user.importStatus === ImportStatus.Failed ? 'bg-red-50' : ''}
${user.importStatus === ImportStatus.Success ? 'bg-green-50' : ''}
${user.importStatus === ImportStatus.Processing ? 'bg-yellow-50' : ''}`}
>
<td className="py-2 px-4 text-sm text-gray-900 truncate max-w-[150px]">
{user.name || '-'}
</td>
<td
className={`py-2 px-4 text-sm truncate max-w-[200px] ${user.valid === false ? 'text-red-500' : 'text-gray-900'}`}
>
{user.email}
</td>
<td className="py-2 px-4 text-sm">
{user.importStatus === ImportStatus.Success ? (
<span className="text-gray-900">
<span className="h-2 w-2 bg-gray-900 rounded-full inline-block mr-2" />
Success
</span>
) : user.importStatus === ImportStatus.Failed ? (
<span className="text-red-500" title={user.importError}>
<span className="h-2 w-2 bg-red-500 rounded-full inline-block mr-2" />
Failed ({user.importError})
</span>
) : user.importStatus === ImportStatus.Processing ? (
<span className="text-yellow-500">
<span className="h-2 w-2 bg-yellow-500 rounded-full inline-block mr-2" />
Processing...
</span>
) : user.valid === false ? (
<span className="text-red-500" title={user.error}>
<span className="h-2 w-2 bg-red-500 rounded-full inline-block mr-2" />
Invalid ({user.error})
</span>
) : (
<span className="text-gray-900">
<span className="h-2 w-2 bg-gray-900 rounded-full inline-block mr-2" />
Valid
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,166 @@
import { toast } from 'sonner';
import { emailRegex } from '../../../utils';
export interface ParsedUser {
name: string | null;
email: string;
valid?: boolean;
error?: string;
importStatus?: ImportStatus;
importError?: string;
}
export enum ImportStatus {
Success = 'success',
Failed = 'failed',
Processing = 'processing',
}
/**
* Validates email addresses for duplicates and format
*/
export const validateEmails = (users: ParsedUser[]): ParsedUser[] => {
const emailMap = new Map<string, number>();
users.forEach(user => {
const lowerCaseEmail = user.email.toLowerCase();
emailMap.set(lowerCaseEmail, (emailMap.get(lowerCaseEmail) || 0) + 1);
});
return users.map(user => {
const lowerCaseEmail = user.email.toLowerCase();
if (!emailRegex.test(user.email)) {
return { ...user, valid: false, error: 'Invalid email format' };
}
const emailCount = emailMap.get(lowerCaseEmail) || 0;
if (emailCount > 1) {
return { ...user, valid: false, error: 'Duplicate email address' };
}
return { ...user, valid: true };
});
};
/**
* Filters valid users for import
*/
export const getValidUsersToImport = (users: ParsedUser[]) => {
return users
.filter(user => user.valid === true)
.map(user => ({
name: user.name || undefined,
email: user.email,
}));
};
/**
* Downloads a CSV template for user import
*/
export const downloadCsvTemplate = () => {
const csvContent = 'Username,Email\n,example@example.com';
downloadCsv(csvContent, 'user_import_template.csv');
};
/**
* Exports failed imports to a CSV file
*/
export const exportImportResults = (results: ParsedUser[]) => {
const csvContent = [
'Username,Email,status',
...results.map(
user =>
`${user.name || ''},${user.email},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
),
].join('\n');
// Create and download the file
downloadCsv(
csvContent,
`import_results_${new Date().toISOString().slice(0, 10)}.csv`
);
toast.success(`Exported ${results.length} import results`);
};
/**
* Utility function for downloading CSV content with proper UTF-8 encoding for international characters
*/
export const downloadCsv = (csvContent: string, filename: string) => {
// Add BOM (Byte Order Mark) to force Excel to interpret the file as UTF-8
const BOM = '\uFEFF';
const csvContentWithBOM = BOM + csvContent;
const blob = new Blob([csvContentWithBOM], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.append(link);
link.click();
setTimeout(() => {
link.remove();
URL.revokeObjectURL(url);
}, 100);
};
/**
* Processes a CSV file to extract user data
*/
export const processCSVFile = async (
file: File,
onSuccess: (users: ParsedUser[]) => void,
onError: () => void
) => {
try {
const csvContent = await file.text();
const rows = csvContent
.split('\n')
.filter(row => row.trim() !== '')
.map(row => row.split(','));
if (rows.length < 2) {
toast.error('CSV file format is incorrect or empty');
onError();
return;
}
const dataRows = rows.slice(1);
const users = dataRows.map(row => ({
name: row[0]?.trim() || null,
email: row[1]?.trim() || '',
}));
const usersWithEmail = users.filter(user => user.email);
if (usersWithEmail.length === 0) {
toast.error('CSV file contains no valid user data');
onError();
return;
}
const validatedUsers = validateEmails(usersWithEmail);
const hasValidUsers = validatedUsers.some(user => user.valid !== false);
if (!hasValidUsers) {
toast.error('CSV file contains no valid user data');
onError();
return;
}
onSuccess(validatedUsers);
} catch (error) {
console.error('Failed to parse CSV file', error);
toast.error('Failed to parse CSV file');
onError();
}
};