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:
128
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal file
128
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal file
80
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal file
97
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
121
apps/remix/app/components/dialogs/folder-rename-dialog.tsx
Normal file
121
apps/remix/app/components/dialogs/folder-rename-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user