feat: add folders (#1711)

This commit is contained in:
Catalin Pit
2025-05-01 19:46:59 +03:00
committed by GitHub
parent 12ada567f5
commit 17370749b4
91 changed files with 10497 additions and 183 deletions

View File

@ -0,0 +1,216 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentMoveToFolderDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveDocumentFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
export const DocumentMoveToFolderDialog = ({
documentId,
open,
onOpenChange,
currentFolderId,
...props
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
defaultValues: {
folderId: currentFolderId,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,
},
{
enabled: open,
},
);
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
useEffect(() => {
if (!open) {
form.reset();
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await moveDocumentToFolder({
documentId,
folderId: data.folderId ?? null,
});
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const documentsPath = formatDocumentsPath(team?.url);
if (data.folderId) {
void navigate(`${documentsPath}/f/${data.folderId}`);
} else {
void navigate(documentsPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the document to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the document.`),
variant: 'destructive',
});
}
};
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a folder to move this document to.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
</Button>
{folders?.data.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={
isFoldersLoading || form.formState.isSubmitting || currentFolderId === null
}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type CreateFolderDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
const form = useForm<TCreateFolderFormSchema>({
resolver: zodResolver(ZCreateFolderFormSchema),
defaultValues: {
name: '',
},
});
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.DOCUMENT,
});
setIsCreateFolderOpen(false);
toast({
description: 'Folder created successfully',
});
const documentsPath = formatDocumentsPath(team?.url);
void navigate(`${documentsPath}/f/${newFolder.id}`);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: 'Failed to create folder',
description: _(msg`This folder name is already taken.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to create folder',
description: _(msg`An unknown error occurred while creating the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isCreateFolderOpen) {
form.reset();
}
}, [isCreateFolderOpen, form]);
return (
<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>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input placeholder="My Folder" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,159 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
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 { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
}),
});
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
const form = useForm<TDeleteFolderFormSchema>({
resolver: zodResolver(ZDeleteFolderFormSchema),
defaultValues: {
confirmText: '',
},
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
});
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to delete does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen]);
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">
This folder contains {folder._count.documents} document(s). Deleting it will also
delete all documents in the folder.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
delete all subfolders and their contents.
</span>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={deleteMessage} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
Delete
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,169 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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 {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
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'>;
const ZMoveFolderFormSchema = z.object({
targetFolderId: z.string().nullable(),
});
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
export const FolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: FolderMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
defaultValues: {
targetFolderId: folder?.parentId ?? null,
},
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return;
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId || null,
});
onOpenChange(false);
toast({
title: 'Folder moved successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to move does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-2">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(null)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
Move Folder
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,173 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderSettingsDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderSettingsDialog = ({
folder,
isOpen,
onOpenChange,
}: FolderSettingsDialogProps) => {
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
name: folder?.name ?? '',
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) return;
try {
await updateFolder({
id: folder.id,
name: data.name,
visibility: isTeamContext
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
title: _(msg`Folder updated successfully`),
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Folder not found`),
});
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Folder Settings</DialogTitle>
<DialogDescription>Manage the settings for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isTeamContext && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
Managers and above
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -24,11 +24,11 @@ import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateCreateDialogProps = {
teamId?: number;
templateRootPath: string;
folderId?: string;
};
export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => {
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { user } = useSession();
@ -53,6 +53,7 @@ export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogP
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId: response.id,
folderId: folderId,
});
toast({

View File

@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type TemplateFolderCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TemplateFolderCreateDialog = ({
trigger,
...props
}: TemplateFolderCreateDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const { folderId } = useParams();
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
const form = useForm<TCreateFolderFormSchema>({
resolver: zodResolver(ZCreateFolderFormSchema),
defaultValues: {
name: '',
},
});
const onSubmit = async (data: TCreateFolderFormSchema) => {
try {
const newFolder = await createFolder({
name: data.name,
parentId: folderId,
type: FolderType.TEMPLATE,
});
setIsCreateFolderOpen(false);
toast({
description: _(msg`Folder created successfully`),
});
const templatesPath = formatTemplatesPath(team?.url);
void navigate(`${templatesPath}/f/${newFolder.id}`);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: _(msg`Failed to create folder`),
description: _(msg`This folder name is already taken.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Failed to create folder`),
description: _(msg`An unknown error occurred while creating the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isCreateFolderOpen) {
form.reset();
}
}, [isCreateFolderOpen, form]);
return (
<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 templates.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input placeholder="My Folder" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,163 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TemplateFolderDeleteDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TemplateFolderDeleteDialog = ({
folder,
isOpen,
onOpenChange,
}: TemplateFolderDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
const ZDeleteFolderFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
}),
});
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
const form = useForm<TDeleteFolderFormSchema>({
resolver: zodResolver(ZDeleteFolderFormSchema),
defaultValues: {
confirmText: '',
},
});
const onFormSubmit = async () => {
if (!folder) return;
try {
await deleteFolder({
id: folder.id,
});
onOpenChange(false);
toast({
title: 'Folder deleted successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to delete does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to delete folder',
description: _(msg`An unknown error occurred while deleting the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen]);
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">
This folder contains {folder._count.documents} document(s). Deleting it will also
delete all documents in the folder.
</span>
)}
{folder && folder._count.subfolders > 0 && (
<span className="text-destructive mt-2 block">
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
delete all subfolders and their contents.
</span>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder={deleteMessage} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
Delete
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,175 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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 {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TemplateFolderMoveDialogProps = {
foldersData: TFolderWithSubfolders[] | undefined;
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveFolderFormSchema = z.object({
targetFolderId: z.string().optional(),
});
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
export const TemplateFolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: TemplateFolderMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
defaultValues: {
targetFolderId: folder?.parentId ?? '',
},
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return;
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId ?? '',
});
onOpenChange(false);
toast({
title: 'Folder moved successfully',
});
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: 'Folder not found',
description: _(msg`The folder you are trying to move does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: 'Failed to move folder',
description: _(msg`An unknown error occurred while moving the folder.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
// Filter out the current folder and only show folders of the same type
const filteredFolders = foldersData?.filter(
(f) => f.id !== folder?.id && f.type === folder?.type,
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>Select a destination for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
<FormField
control={form.control}
name="targetFolderId"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-2">
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className="w-full justify-start"
disabled={!folder?.parentId}
onClick={() => field.onChange(undefined)}
>
<HomeIcon className="mr-2 h-4 w-4" />
Root
</Button>
{filteredFolders &&
filteredFolders.map((f) => (
<Button
key={f.id}
type="button"
disabled={f.id === folder?.parentId}
variant={field.value === f.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(f.id)}
>
<FolderIcon className="mr-2 h-4 w-4" />
{f.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={
form.formState.isSubmitting ||
form.getValues('targetFolderId') === folder?.parentId
}
>
Move Folder
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,176 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { FolderType } from '@documenso/lib/types/folder-type';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateFolderSettingsDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const TemplateFolderSettingsDialog = ({
folder,
isOpen,
onOpenChange,
}: TemplateFolderSettingsDialogProps) => {
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const isTemplateFolder = folder?.type === FolderType.TEMPLATE;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
name: folder?.name ?? '',
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) return;
try {
await updateFolder({
id: folder.id,
name: data.name,
visibility:
isTeamContext && !isTemplateFolder
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
title: _(msg`Folder updated successfully`),
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Folder not found`),
});
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Folder Settings</DialogTitle>
<DialogDescription>Manage the settings for this folder.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isTeamContext && !isTemplateFolder && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
Managers and above
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type="submit">Save Changes</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,213 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await moveTemplateToFolder({
templateId,
folderId: data.folderId ?? null,
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team?.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
</Button>
{folders?.data?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}