feat: implement folder management dialogs

- Added Create, Delete, Move, and Rename dialogs for folder management.
- Integrated TRPC mutations for folder operations.
- Enhanced user experience with toast notifications for success and error handling.
- Utilized Radix UI for dialog components and Tailwind CSS for styling.
This commit is contained in:
Catalin Pit
2025-03-18 17:55:29 +02:00
parent 3fbf6c5842
commit bad71d92c5
4 changed files with 426 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
import { useMemo, useState } from 'react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { z } from 'zod';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
folderId: z
.string()
.transform((val) => parseInt(val, 10))
.optional(),
});
export type CreateFolderDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
const { toast } = useToast();
const [searchParams] = useSearchParams();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const currentFolderId = findDocumentSearchParams.folderId;
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
const handleCreateFolder = async () => {
if (!newFolderName.trim()) {
toast({
title: 'Folder name is required',
variant: 'destructive',
});
return;
}
try {
await createFolder({
name: newFolderName,
parentId: currentFolderId,
});
setNewFolderName('');
setIsCreateFolderOpen(false);
toast({
title: 'Folder created successfully',
});
} catch (error) {
console.error('Error creating folder:', error);
toast({
title: 'Failed to create folder',
variant: 'destructive',
});
}
};
return (
<div className="mt-4 flex justify-end">
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="flex items-center space-x-2">
<FolderPlusIcon className="h-4 w-4" />
<span>Create Folder</span>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogDescription>
Enter a name for your new folder. Folders help you organize your documents.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="folder-name">Folder Name</Label>
<Input
id="folder-name"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="My Folder"
className="mt-2"
/>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setIsCreateFolderOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateFolder}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,80 @@
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const handleDeleteFolder = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
});
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
});
} catch (error) {
console.error('Error deleting folder:', error);
toast({
title: 'Failed to delete folder',
variant: 'destructive',
});
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Folder</DialogTitle>
<DialogDescription>
Are you sure you want to delete this folder?
{folder && folder._count.documents > 0 && (
<span className="text-destructive mt-2 block">
Warning: This folder contains {folder._count.documents} document(s). Deleting it
will move all documents to the root.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
Warning: This folder contains {folder._count.subfolders} subfolder(s). Deleting it
will delete all subfolders and their contents.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteFolder}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,97 @@
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderMoveDialogProps = {
foldersData: TFolderWithSubfolders[] | undefined;
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: FolderMoveDialogProps) => {
const { toast } = useToast();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const handleMoveFolder = async (targetFolderId: number | null) => {
if (!folder) return;
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId,
});
onOpenChange(false);
toast({
title: 'Folder moved successfully',
});
} catch (error) {
console.error('Error moving folder:', error);
toast({
title: 'Failed to move folder',
variant: 'destructive',
});
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Button
variant="outline"
className="w-full justify-start"
onClick={async () => await handleMoveFolder(null)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{foldersData &&
foldersData
.filter((f) => f.id !== folder?.id)
.map((f) => (
<Button
key={f.id}
variant="outline"
className="w-full justify-start"
onClick={async () => await handleMoveFolder(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type FolderRenameDialogProps = {
trigger?: React.ReactNode;
folder?: TFolderWithSubfolders | null;
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderRenameDialog = ({
trigger,
folder,
isOpen,
onOpenChange,
...props
}: FolderRenameDialogProps) => {
const { toast } = useToast();
const [isRenameFolderOpen, setIsRenameFolderOpen] = useState(isOpen || false);
const [renameFolderName, setRenameFolderName] = useState('');
const [folderToRename, setFolderToRename] = useState<TFolderWithSubfolders | null>(
folder || null,
);
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
useEffect(() => {
if (isOpen !== undefined) {
setIsRenameFolderOpen(isOpen);
}
if (folder && isOpen) {
setFolderToRename(folder);
setRenameFolderName(folder.name);
}
}, [isOpen, folder]);
const handleOpenChange = (open: boolean) => {
setIsRenameFolderOpen(open);
if (onOpenChange) {
onOpenChange(open);
}
};
const handleRenameFolder = async () => {
if (!folderToRename) return;
if (!renameFolderName.trim()) {
toast({
title: 'Folder name is required',
variant: 'destructive',
});
return;
}
try {
await updateFolder({
id: folderToRename.id,
name: renameFolderName,
});
toast({
title: 'Folder renamed successfully',
});
setFolderToRename(null);
setRenameFolderName('');
handleOpenChange(false);
} catch (error) {
console.error('Error renaming folder:', error);
toast({
title: 'Failed to rename folder',
variant: 'destructive',
});
}
};
return (
<Dialog {...props} open={isRenameFolderOpen} onOpenChange={handleOpenChange}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Folder</DialogTitle>
<DialogDescription>Enter a new name for your folder.</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="rename-folder-name">Folder Name</Label>
<Input
id="rename-folder-name"
value={renameFolderName}
onChange={(e) => setRenameFolderName(e.target.value)}
className="mt-2"
/>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleRenameFolder}>Rename</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};