From bad71d92c50c084412c61a9f2e6c8e8ca3dfed34 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 18 Mar 2025 17:55:29 +0200 Subject: [PATCH] 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. --- .../dialogs/folder-create-dialog.tsx | 128 ++++++++++++++++++ .../dialogs/folder-delete-dialog.tsx | 80 +++++++++++ .../components/dialogs/folder-move-dialog.tsx | 97 +++++++++++++ .../dialogs/folder-rename-dialog.tsx | 121 +++++++++++++++++ 4 files changed, 426 insertions(+) create mode 100644 apps/remix/app/components/dialogs/folder-create-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/folder-delete-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/folder-move-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/folder-rename-dialog.tsx diff --git a/apps/remix/app/components/dialogs/folder-create-dialog.tsx b/apps/remix/app/components/dialogs/folder-create-dialog.tsx new file mode 100644 index 000000000..054023694 --- /dev/null +++ b/apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -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; + +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 ( +
+ + + {trigger ?? ( + + )} + + + + Create New Folder + + Enter a name for your new folder. Folders help you organize your documents. + + +
+ + setNewFolderName(e.target.value)} + placeholder="My Folder" + className="mt-2" + /> +
+ + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/folder-delete-dialog.tsx b/apps/remix/app/components/dialogs/folder-delete-dialog.tsx new file mode 100644 index 000000000..7858e74dd --- /dev/null +++ b/apps/remix/app/components/dialogs/folder-delete-dialog.tsx @@ -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; + +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 ( + + + + Delete Folder + + Are you sure you want to delete this folder? + {folder && folder._count.documents > 0 && ( + + Warning: This folder contains {folder._count.documents} document(s). Deleting it + will move all documents to the root. + + )} + {folder && folder._count.subfolders > 0 && ( + + Warning: This folder contains {folder._count.subfolders} subfolder(s). Deleting it + will delete all subfolders and their contents. + + )} + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/folder-move-dialog.tsx b/apps/remix/app/components/dialogs/folder-move-dialog.tsx new file mode 100644 index 000000000..000d336e0 --- /dev/null +++ b/apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -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; + +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 ( + + + + Move Folder + Select a destination for this folder. + +
+ + + {foldersData && + foldersData + .filter((f) => f.id !== folder?.id) + .map((f) => ( + + ))} +
+ + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/folder-rename-dialog.tsx b/apps/remix/app/components/dialogs/folder-rename-dialog.tsx new file mode 100644 index 000000000..0c4ce5a32 --- /dev/null +++ b/apps/remix/app/components/dialogs/folder-rename-dialog.tsx @@ -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; + +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( + 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 ( + + {trigger && {trigger}} + + + Rename Folder + Enter a new name for your folder. + +
+ + setRenameFolderName(e.target.value)} + className="mt-2" + /> +
+ + + + +
+
+ ); +};