mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 20:38:52 +00:00
feat(admin): add import and export users to admin panel (#10810)
This commit is contained in:
@@ -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} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
166
packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts
Normal file
166
packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user