mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
11 Commits
fix/stagin
...
v1.10.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ac7d24eb12 | |||
| 0931c472a7 | |||
| 8c9dd5e372 | |||
| e108da546d | |||
| 17370749b4 | |||
| 12ada567f5 | |||
| bdb0b0ea88 | |||
| 6a41a37bd4 | |||
| d78cfec00e | |||
| f0dcf7e9bf | |||
| 6540291055 |
@ -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>
|
||||
);
|
||||
};
|
||||
@ -4,14 +4,14 @@ 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 { Team } from '@prisma/client';
|
||||
import { type Document, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -43,9 +43,7 @@ import { StackAvatar } from '../general/stack-avatar';
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type DocumentResendDialogProps = {
|
||||
document: Document & {
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
document: TDocumentRow;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
|
||||
163
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal file
163
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
159
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal file
159
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
169
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal file
169
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
173
apps/remix/app/components/dialogs/folder-settings-dialog.tsx
Normal file
173
apps/remix/app/components/dialogs/folder-settings-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 "{templateTitle}" 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>
|
||||
);
|
||||
}
|
||||
@ -57,7 +57,7 @@ export const ConfigureDocumentRecipients = ({
|
||||
name: 'signers',
|
||||
});
|
||||
|
||||
const { getValues, watch } = useFormContext<TConfigureEmbedFormSchema>();
|
||||
const { getValues, watch, setValue } = useFormContext<TConfigureEmbedFormSchema>();
|
||||
|
||||
const signingOrder = watch('meta.signingOrder');
|
||||
|
||||
@ -67,13 +67,16 @@ export const ConfigureDocumentRecipients = ({
|
||||
|
||||
const onAddSigner = useCallback(() => {
|
||||
const signerNumber = signers.length + 1;
|
||||
const recipientSigningOrder =
|
||||
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
|
||||
|
||||
appendSigner({
|
||||
formId: nanoid(8),
|
||||
name: isTemplate ? `Recipient ${signerNumber}` : '',
|
||||
email: isTemplate ? `recipient.${signerNumber}@document.com` : '',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1,
|
||||
signingOrder:
|
||||
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
|
||||
});
|
||||
}, [appendSigner, signers]);
|
||||
|
||||
@ -103,7 +106,7 @@ export const ConfigureDocumentRecipients = ({
|
||||
// Update signing order for each item
|
||||
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
|
||||
...s,
|
||||
signingOrder: idx + 1,
|
||||
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
|
||||
}));
|
||||
|
||||
// Update the form
|
||||
@ -123,7 +126,7 @@ export const ConfigureDocumentRecipients = ({
|
||||
const currentSigners = getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
|
||||
...signer,
|
||||
signingOrder: index + 1,
|
||||
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
|
||||
}));
|
||||
|
||||
// Update the form with new ordering
|
||||
@ -132,6 +135,16 @@ export const ConfigureDocumentRecipients = ({
|
||||
[move, replace, getValues],
|
||||
);
|
||||
|
||||
const onSigningOrderChange = (signingOrder: DocumentSigningOrder) => {
|
||||
setValue('meta.signingOrder', signingOrder);
|
||||
|
||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
signers.forEach((_signer, index) => {
|
||||
setValue(`signers.${index}.signingOrder`, index + 1);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-foreground mb-1 text-lg font-medium">
|
||||
@ -152,11 +165,11 @@ export const ConfigureDocumentRecipients = ({
|
||||
{...field}
|
||||
id="signingOrder"
|
||||
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(
|
||||
onCheckedChange={(checked) =>
|
||||
onSigningOrderChange(
|
||||
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
||||
);
|
||||
}}
|
||||
)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -184,6 +197,7 @@ export const ConfigureDocumentRecipients = ({
|
||||
disabled={isSubmitting || !isSigningOrderEnabled}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="flex items-center">
|
||||
<FormLabel
|
||||
htmlFor="allowDictateNextSigner"
|
||||
@ -227,13 +241,14 @@ export const ConfigureDocumentRecipients = ({
|
||||
key={signer.id}
|
||||
draggableId={signer.id}
|
||||
index={index}
|
||||
isDragDisabled={!isSigningOrderEnabled || isSubmitting}
|
||||
isDragDisabled={!isSigningOrderEnabled || isSubmitting || signer.disabled}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
<fieldset
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
disabled={signer.disabled}
|
||||
className={cn('py-1', {
|
||||
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||
snapshot.isDragging,
|
||||
@ -349,7 +364,7 @@ export const ConfigureDocumentRecipients = ({
|
||||
{...field}
|
||||
isAssistantEnabled={isSigningOrderEnabled}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isSubmitting || snapshot.isDragging}
|
||||
disabled={isSubmitting || snapshot.isDragging || signer.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -360,13 +375,18 @@ export const ConfigureDocumentRecipients = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isSubmitting || signers.length === 1 || snapshot.isDragging}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
signers.length === 1 ||
|
||||
snapshot.isDragging ||
|
||||
signer.disabled
|
||||
}
|
||||
onClick={() => removeSigner(index)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
@ -30,10 +30,15 @@ import {
|
||||
export interface ConfigureDocumentViewProps {
|
||||
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
|
||||
defaultValues?: Partial<TConfigureEmbedFormSchema>;
|
||||
disableUpload?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocumentViewProps) => {
|
||||
export const ConfigureDocumentView = ({
|
||||
onSubmit,
|
||||
defaultValues,
|
||||
disableUpload,
|
||||
}: ConfigureDocumentViewProps) => {
|
||||
const { isTemplate } = useConfigureDocument();
|
||||
|
||||
const form = useForm<TConfigureEmbedFormSchema>({
|
||||
@ -47,6 +52,7 @@ export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocu
|
||||
email: isTemplate ? `recipient.${1}@document.com` : '',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
@ -110,7 +116,7 @@ export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocu
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfigureDocumentUpload isSubmitting={isSubmitting} />
|
||||
{!disableUpload && <ConfigureDocumentUpload isSubmitting={isSubmitting} />}
|
||||
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
|
||||
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
|
||||
|
||||
|
||||
@ -15,11 +15,13 @@ export const ZConfigureEmbedFormSchema = z.object({
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
formId: z.string(),
|
||||
name: z.string().min(1, { message: 'Name is required' }),
|
||||
email: z.string().email('Invalid email address'),
|
||||
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||
signingOrder: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1, { message: 'At least one signer is required' }),
|
||||
@ -34,7 +36,7 @@ export const ZConfigureEmbedFormSchema = z.object({
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
signatureTypes: z.array(z.string()).default([]),
|
||||
signingOrder: z.enum(['SEQUENTIAL', 'PARALLEL']),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
allowDictateNextSigner: z.boolean().default(false).optional(),
|
||||
externalId: z.string().optional(),
|
||||
}),
|
||||
documentData: z
|
||||
|
||||
@ -2,12 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import { FieldType, ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import type { DocumentData, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
@ -30,6 +29,7 @@ import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/shee
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
|
||||
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
|
||||
|
||||
const MIN_HEIGHT_PX = 12;
|
||||
@ -38,28 +38,9 @@ const MIN_WIDTH_PX = 36;
|
||||
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
|
||||
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||
|
||||
export const ZConfigureFieldsFormSchema = z.object({
|
||||
fields: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number().min(0),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema.optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
|
||||
|
||||
export type ConfigureFieldsViewProps = {
|
||||
configData: TConfigureEmbedFormSchema;
|
||||
documentData?: DocumentData;
|
||||
defaultValues?: Partial<TConfigureFieldsFormSchema>;
|
||||
onBack: (data: TConfigureFieldsFormSchema) => void;
|
||||
onSubmit: (data: TConfigureFieldsFormSchema) => void;
|
||||
@ -67,13 +48,14 @@ export type ConfigureFieldsViewProps = {
|
||||
|
||||
export const ConfigureFieldsView = ({
|
||||
configData,
|
||||
documentData,
|
||||
defaultValues,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: ConfigureFieldsViewProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||
const { _ } = useLingui();
|
||||
|
||||
// Track if we're on a mobile device
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
@ -99,7 +81,11 @@ export const ConfigureFieldsView = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const documentData = useMemo(() => {
|
||||
const normalizedDocumentData = useMemo(() => {
|
||||
if (documentData) {
|
||||
return documentData;
|
||||
}
|
||||
|
||||
if (!configData.documentData) {
|
||||
return null;
|
||||
}
|
||||
@ -116,7 +102,7 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
return configData.signers.map<Recipient>((signer, index) => ({
|
||||
id: index,
|
||||
id: signer.nativeId || index,
|
||||
name: signer.name || '',
|
||||
email: signer.email || '',
|
||||
role: signer.role,
|
||||
@ -129,14 +115,14 @@ export const ConfigureFieldsView = ({
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
rejectionReason: null,
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
||||
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||
}));
|
||||
}, [configData.signers]);
|
||||
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
|
||||
() => recipients[0] || null,
|
||||
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
|
||||
);
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||
@ -206,8 +192,8 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
id: nanoid(12),
|
||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
@ -229,8 +215,8 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
append({
|
||||
...copiedField,
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
id: nanoid(12),
|
||||
signerEmail: selectedRecipient?.email ?? copiedField.signerEmail,
|
||||
recipientId: selectedRecipient?.id ?? copiedField.recipientId,
|
||||
pageX: copiedField.pageX + 3,
|
||||
@ -303,7 +289,6 @@ export const ConfigureFieldsView = ({
|
||||
pageY -= fieldPageHeight / 2;
|
||||
|
||||
const field = {
|
||||
id: nanoid(12),
|
||||
formId: nanoid(12),
|
||||
type: selectedField,
|
||||
pageNumber,
|
||||
@ -526,9 +511,9 @@ export const ConfigureFieldsView = ({
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
{documentData && (
|
||||
{normalizedDocumentData && (
|
||||
<div>
|
||||
<PDFViewer documentData={documentData} />
|
||||
<PDFViewer documentData={normalizedDocumentData} />
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{localFields.map((field, index) => {
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZConfigureFieldsFormSchema = z.object({
|
||||
fields: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
formId: z.string().min(1),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
inserted: z.boolean().optional(),
|
||||
recipientId: z.number().min(0),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema.optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
|
||||
|
||||
export type TConfigureFieldsFormSchemaField = z.infer<
|
||||
typeof ZConfigureFieldsFormSchema
|
||||
>['fields'][number];
|
||||
@ -1,6 +1,5 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { FieldType } from '@prisma/client';
|
||||
|
||||
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||
@ -8,35 +7,13 @@ import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/fi
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
|
||||
|
||||
import type { TConfigureFieldsFormSchemaField } from './configure-fields-view.types';
|
||||
|
||||
export type FieldAdvancedSettingsDrawerProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
currentField: {
|
||||
id: string;
|
||||
formId: string;
|
||||
type: FieldType;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
recipientId: number;
|
||||
signerEmail: string;
|
||||
fieldMeta?: FieldMeta;
|
||||
} | null;
|
||||
fields: Array<{
|
||||
id: string;
|
||||
formId: string;
|
||||
type: FieldType;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
recipientId: number;
|
||||
signerEmail: string;
|
||||
fieldMeta?: FieldMeta;
|
||||
}>;
|
||||
currentField: TConfigureFieldsFormSchemaField | null;
|
||||
fields: TConfigureFieldsFormSchemaField[];
|
||||
onFieldUpdate: (formId: string, fieldMeta: FieldMeta) => void;
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
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 { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
||||
|
||||
export type DocumentCertificateQRViewProps = {
|
||||
documentId: number;
|
||||
title: string;
|
||||
documentData: DocumentData;
|
||||
password?: string | null;
|
||||
recipientCount?: number;
|
||||
completedDate?: Date;
|
||||
};
|
||||
|
||||
export const DocumentCertificateQRView = ({
|
||||
documentId,
|
||||
title,
|
||||
documentData,
|
||||
password,
|
||||
recipientCount = 0,
|
||||
completedDate,
|
||||
}: DocumentCertificateQRViewProps) => {
|
||||
const { data: documentUrl } = trpc.shareLink.getDocumentInternalUrlForQRCode.useQuery({
|
||||
documentId,
|
||||
});
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentUrl);
|
||||
|
||||
const formattedDate = completedDate
|
||||
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (documentUrl) {
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
}, [documentUrl]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-md">
|
||||
{/* Dialog for internal document link */}
|
||||
{documentUrl && (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Document found in your account</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
This document is available in your Documenso account. You can view more details,
|
||||
recipients, and audit logs there.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||
<Button asChild>
|
||||
<a href={documentUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Go to document</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
|
||||
<p>
|
||||
<Trans>{recipientCount} recipients</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>Completed on {formattedDate}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareDocumentDownloadButton title={title} documentData={documentData} />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 w-full">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} password={password} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,192 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export interface DocumentDropZoneWrapperProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const userTimezone =
|
||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||
DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
|
||||
const { quota, remaining, refreshLimits } = useLimits();
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
|
||||
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
if (isUploadDisabled && IS_BILLING_ENABLED()) {
|
||||
await navigate('/settings/billing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
void refreshLimits();
|
||||
|
||||
toast({
|
||||
title: _(msg`Document uploaded`),
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await navigate(
|
||||
folderId
|
||||
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
|
||||
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
|
||||
);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title: _(msg`Your document failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
//disabled: isUploadDisabled,
|
||||
multiple: false,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
onDrop: ([acceptedFile]) => {
|
||||
if (acceptedFile) {
|
||||
void onFileDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
onDropRejected: () => {
|
||||
void onFileDropRejected();
|
||||
},
|
||||
noClick: true,
|
||||
noDragEventsBubbling: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
|
||||
{isDragActive && (
|
||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||
<h2 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Upload Document</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-md mt-4">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
</p>
|
||||
|
||||
{isUploadDisabled && IS_BILLING_ENABLED() && (
|
||||
<Link
|
||||
to="/settings/billing"
|
||||
className="mt-4 text-sm text-amber-500 hover:underline dark:text-amber-400"
|
||||
>
|
||||
<Trans>Upgrade your plan to upload more documents</Trans>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isUploadDisabled &&
|
||||
team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/80 mt-4 text-sm">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||
<p className="text-foreground mt-8 font-medium">
|
||||
<Trans>Uploading document...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -38,6 +38,9 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
||||
const role = recipient?.role;
|
||||
|
||||
const documentsPath = formatDocumentsPath(document.team?.url);
|
||||
const formatPath = document.folderId
|
||||
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
|
||||
: `${documentsPath}/${document.id}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -101,7 +104,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
||||
))
|
||||
.with({ isComplete: false }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
<Link to={`${documentsPath}/${document.id}/edit`}>
|
||||
<Link to={formatPath}>
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -3,8 +3,8 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
@ -15,8 +15,7 @@ import {
|
||||
Share,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
@ -99,6 +98,35 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadOriginalClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||
{
|
||||
documentId: document.id,
|
||||
},
|
||||
{
|
||||
context: {
|
||||
teamId: team?.id?.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const documentData = documentWithData?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: document.title, version: 'original' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nonSignedRecipients = document.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
@ -128,6 +156,11 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`${documentsPath}/${document.id}/logs`}>
|
||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||
|
||||
@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
@ -17,7 +17,13 @@ import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
@ -30,6 +36,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
@ -69,6 +76,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
void refreshLimits();
|
||||
@ -85,7 +93,11 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||
await navigate(
|
||||
folderId
|
||||
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
|
||||
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
|
||||
);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@ -121,25 +133,31 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
className="h-[min(400px,50vh)]"
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DocumentDropzone
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<TooltipContent>
|
||||
<p className="text-sm">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
|
||||
88
apps/remix/app/components/general/folder/folder-card.tsx
Normal file
88
apps/remix/app/components/general/folder/folder-card.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { FolderIcon, PinIcon } from 'lucide-react';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
export type FolderCardProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
onNavigate: (folderId: string) => void;
|
||||
onMove: (folder: TFolderWithSubfolders) => void;
|
||||
onPin: (folderId: string) => void;
|
||||
onUnpin: (folderId: string) => void;
|
||||
onSettings: (folder: TFolderWithSubfolders) => void;
|
||||
onDelete: (folder: TFolderWithSubfolders) => void;
|
||||
};
|
||||
|
||||
export const FolderCard = ({
|
||||
folder,
|
||||
onNavigate,
|
||||
onMove,
|
||||
onPin,
|
||||
onUnpin,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: FolderCardProps) => {
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<button
|
||||
className="flex items-center space-x-2 text-left"
|
||||
onClick={() => onNavigate(folder.id)}
|
||||
>
|
||||
<FolderIcon className="text-documenso h-6 w-6" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{folder.name}</h3>
|
||||
{folder.pinned && <PinIcon className="text-documenso h-3 w-3" />}
|
||||
</div>
|
||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
||||
<span>
|
||||
{formatFolderCount(
|
||||
folder.type === FolderType.TEMPLATE
|
||||
? folder._count.templates
|
||||
: folder._count.documents,
|
||||
folder.type === FolderType.TEMPLATE ? 'template' : 'document',
|
||||
folder.type === FolderType.TEMPLATE ? 'templates' : 'documents',
|
||||
)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatFolderCount(folder._count.subfolders, 'folder', 'folders')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100">
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onMove(folder)}>Move</DropdownMenuItem>
|
||||
{folder.pinned ? (
|
||||
<DropdownMenuItem onClick={() => onUnpin(folder.id)}>Unpin</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onPin(folder.id)}>Pin</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onSettings(folder)}>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-500" onClick={() => onDelete(folder)}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import type { DocumentData } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type ShareDocumentDownloadButtonProps = {
|
||||
title: string;
|
||||
documentData: DocumentData;
|
||||
};
|
||||
|
||||
export const ShareDocumentDownloadButton = ({
|
||||
title,
|
||||
documentData,
|
||||
}: ShareDocumentDownloadButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 4000);
|
||||
});
|
||||
|
||||
await downloadPDF({ documentData, fileName: title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={isDownloading} onClick={onDownloadClick}>
|
||||
{!isDownloading && <Download className="mr-2 h-4 w-4" />}
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -208,7 +208,11 @@ export const TemplateEditForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(templateRootPath);
|
||||
const templatePath = template.folderId
|
||||
? `${templateRootPath}/f/${template.folderId}`
|
||||
: templateRootPath;
|
||||
|
||||
await navigate(templatePath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@ -14,9 +14,13 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
isTeam: boolean;
|
||||
};
|
||||
|
||||
type LeaderboardTableProps = {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
@ -9,6 +8,7 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
@ -18,11 +18,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentsTableActionButtonProps = {
|
||||
row: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
row: TDocumentRow;
|
||||
};
|
||||
|
||||
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
||||
@ -44,6 +40,9 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
const formatPath = row.folderId
|
||||
? `${documentsPath}/f/${row.folderId}/${row.id}/edit`
|
||||
: `${documentsPath}/${row.id}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -96,7 +95,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||
() => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link to={`${documentsPath}/${row.id}/edit`}>
|
||||
<Link to={formatPath}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
|
||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
||||
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
||||
import {
|
||||
CheckCircle,
|
||||
@ -22,6 +21,7 @@ import { Link } from 'react-router';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
@ -43,14 +43,14 @@ import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/d
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentsTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
row: TDocumentRow;
|
||||
onMoveDocument?: () => void;
|
||||
};
|
||||
|
||||
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
|
||||
export const DocumentsTableActionDropdown = ({
|
||||
row,
|
||||
onMoveDocument,
|
||||
}: DocumentsTableActionDropdownProps) => {
|
||||
const { user } = useSession();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
@ -73,6 +73,9 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
const formatPath = row.folderId
|
||||
? `${documentsPath}/f/${row.folderId}/${row.id}/edit`
|
||||
: `${documentsPath}/${row.id}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -100,6 +103,32 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadOriginalClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.getDocumentById.query({
|
||||
documentId: row.id,
|
||||
})
|
||||
: await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title, version: 'original' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
@ -141,7 +170,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||
<Link to={`${documentsPath}/${row.id}/edit`}>
|
||||
<Link to={formatPath}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
@ -152,6 +181,11 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
||||
<Trans>Download</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
@ -165,6 +199,13 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{onMoveDocument && (
|
||||
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* No point displaying this if there's no functionality. */}
|
||||
{/* <DropdownMenuItem disabled>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
export type DataTableTitleProps = {
|
||||
row: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
team: Pick<Team, 'url'> | null;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
row: TDocumentRow;
|
||||
teamUrl?: string;
|
||||
};
|
||||
|
||||
|
||||
@ -29,11 +29,17 @@ export type DocumentsTableProps = {
|
||||
data?: TFindDocumentsResponse;
|
||||
isLoading?: boolean;
|
||||
isLoadingError?: boolean;
|
||||
onMoveDocument?: (documentId: number) => void;
|
||||
};
|
||||
|
||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
||||
|
||||
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
|
||||
export const DocumentsTable = ({
|
||||
data,
|
||||
isLoading,
|
||||
isLoadingError,
|
||||
onMoveDocument,
|
||||
}: DocumentsTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
@ -80,12 +86,15 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
|
||||
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentsTableActionButton row={row.original} />
|
||||
<DocumentsTableActionDropdown row={row.original} />
|
||||
<DocumentsTableActionDropdown
|
||||
row={row.original}
|
||||
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
||||
}, [team]);
|
||||
}, [team, onMoveDocument]);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
@ -171,6 +180,9 @@ const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
||||
|
||||
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
||||
const formatPath = row.folderId
|
||||
? `${documentsPath}/f/${row.folderId}/${row.id}`
|
||||
: `${documentsPath}/${row.id}`;
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
@ -179,7 +191,7 @@ const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
})
|
||||
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
||||
<Link
|
||||
to={`${documentsPath}/${row.id}`}
|
||||
to={formatPath}
|
||||
title={row.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
|
||||
@ -2,7 +2,16 @@ import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
Edit,
|
||||
FolderIcon,
|
||||
MoreHorizontal,
|
||||
MoveRight,
|
||||
Share2Icon,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
@ -19,6 +28,7 @@ import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
|
||||
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
|
||||
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
|
||||
import { TemplateMoveDialog } from '../dialogs/template-move-dialog';
|
||||
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
|
||||
|
||||
export type TemplatesTableActionDropdownProps = {
|
||||
row: Template & {
|
||||
@ -50,13 +60,20 @@ export const TemplatesTableActionDropdown = ({
|
||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
|
||||
|
||||
const isOwner = row.userId === user.id;
|
||||
const isTeamTemplate = row.teamId === teamId;
|
||||
|
||||
const formatPath = row.folderId
|
||||
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
||||
: `${templateRootPath}/${row.id}/edit`;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@ -64,7 +81,7 @@ export const TemplatesTableActionDropdown = ({
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||
<Link to={`${templateRootPath}/${row.id}/edit`}>
|
||||
<Link to={formatPath}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
@ -83,6 +100,11 @@ export const TemplatesTableActionDropdown = ({
|
||||
<Trans>Direct link</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!teamId && !row.teamId && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
@ -135,6 +157,14 @@ export const TemplatesTableActionDropdown = ({
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
<TemplateMoveToFolderDialog
|
||||
templateId={row.id}
|
||||
templateTitle={row.title}
|
||||
isOpen={isMoveToFolderDialogOpen}
|
||||
onOpenChange={setMoveToFolderDialogOpen}
|
||||
currentFolderId={row.folderId}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@ -54,6 +54,11 @@ export const TemplatesTable = ({
|
||||
const formatTemplateLink = (row: TemplatesTableRow) => {
|
||||
const isCurrentTeamTemplate = team?.url && row.team?.url === team?.url;
|
||||
const path = formatTemplatesPath(isCurrentTeamTemplate ? team?.url : undefined);
|
||||
|
||||
if (row.folderId) {
|
||||
return `${path}/f/${row.folderId}/${row.id}`;
|
||||
}
|
||||
|
||||
return `${path}/${row.id}`;
|
||||
};
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ export default function AdminDocumentsPage() {
|
||||
<TooltipTrigger>
|
||||
<Link to={`/admin/users/${row.original.user.id}`}>
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
{avatarFallbackText}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@ -99,7 +99,7 @@ export default function AdminDocumentsPage() {
|
||||
|
||||
<TooltipContent className="flex max-w-xs items-center gap-2">
|
||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
{avatarFallbackText}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@ -2,7 +2,10 @@ import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||
|
||||
import { AdminLeaderboardTable } from '~/components/tables/admin-leaderboard-table';
|
||||
import {
|
||||
AdminLeaderboardTable,
|
||||
type SigningVolume,
|
||||
} from '~/components/tables/admin-leaderboard-table';
|
||||
|
||||
import type { Route } from './+types/leaderboard';
|
||||
|
||||
@ -25,7 +28,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||
const search = url.searchParams.get('search') || '';
|
||||
|
||||
const { leaderboard: signingVolume, totalPages } = await getSigningVolume({
|
||||
const { leaderboard, totalPages } = await getSigningVolume({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
@ -33,8 +36,14 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
|
||||
...item,
|
||||
name: item.name || '',
|
||||
createdAt: item.createdAt || new Date(),
|
||||
}));
|
||||
|
||||
return {
|
||||
signingVolume,
|
||||
signingVolume: typedSigningVolume,
|
||||
totalPages,
|
||||
page,
|
||||
perPage,
|
||||
@ -48,9 +57,11 @@ export default function Leaderboard({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Signing Volume</Trans>
|
||||
</h2>
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Signing Volume</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<AdminLeaderboardTable
|
||||
signingVolume={signingVolume}
|
||||
|
||||
@ -64,6 +64,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (document?.folderId) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const documentVisibility = document?.visibility;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
@ -49,6 +49,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (document?.folderId) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const documentVisibility = document?.visibility;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||
@ -83,7 +87,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
document,
|
||||
document: {
|
||||
...document,
|
||||
folder: null,
|
||||
},
|
||||
documentRootPath,
|
||||
isDocumentEnterprise,
|
||||
});
|
||||
|
||||
@ -43,24 +43,26 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
// Todo: Get full document instead?
|
||||
const [document, recipients] = await Promise.all([
|
||||
getDocumentById({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null),
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}),
|
||||
]);
|
||||
const document = await getDocumentById({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (document.folderId) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const recipients = await getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
document,
|
||||
documentRootPath,
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
@ -14,12 +16,21 @@ import {
|
||||
type TFindDocumentsInternalResponse,
|
||||
ZFindDocumentsInternalRequestSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||
import { PeriodSelector } from '~/components/general/period-selector';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
@ -43,9 +54,22 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
@ -66,9 +90,18 @@ export default function DocumentsPage() {
|
||||
},
|
||||
);
|
||||
|
||||
// Refetch the documents when the team URL changes.
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: isFoldersLoading,
|
||||
refetch: refetchFolders,
|
||||
} = trpc.folder.getFolders.useQuery({
|
||||
type: FolderType.DOCUMENT,
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
void refetchFolders();
|
||||
}, [team?.url]);
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
@ -93,76 +126,265 @@ export default function DocumentsPage() {
|
||||
}
|
||||
}, [data?.stats]);
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (folderId) {
|
||||
void navigate(`${documentsPath}/f/${folderId}`);
|
||||
} else {
|
||||
void navigate(documentsPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewAllFolders = () => {
|
||||
void navigate(`${formatDocumentsPath(team?.url)}/folders`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<DocumentUploadDropzone />
|
||||
<DocumentDropZoneWrapper>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(null)}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Documents</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
{foldersData?.breadcrumbs.map((folder) => (
|
||||
<div key={folder.id} className="flex items-center space-x-2">
|
||||
<span>/</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(folder.id)}
|
||||
>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
<DocumentUploadDropzone />
|
||||
<CreateFolderDialog />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState
|
||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||
/>
|
||||
) : (
|
||||
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
|
||||
)}
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{foldersData?.folders?.some((folder) => folder.pinned) && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData.folders
|
||||
.filter((folder) => folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={navigateToFolder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData?.folders
|
||||
?.filter((folder) => !folder.pinned)
|
||||
.slice(0, 12)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={navigateToFolder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center">
|
||||
{foldersData && foldersData.folders?.length > 12 && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void handleViewAllFolders()}
|
||||
>
|
||||
View all folders
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data &&
|
||||
data.count === 0 &&
|
||||
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
|
||||
<DocumentsTableEmptyState
|
||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||
/>
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
onMoveDocument={(documentId) => {
|
||||
setDocumentToMove(documentId);
|
||||
setIsMovingDocument(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{documentToMove && (
|
||||
<DocumentMoveToFolderDialog
|
||||
documentId={documentToMove}
|
||||
open={isMovingDocument}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingDocument(open);
|
||||
|
||||
if (!open) {
|
||||
setDocumentToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderSettingsDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentDropZoneWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,272 @@
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
|
||||
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
|
||||
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
|
||||
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
|
||||
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
} from '~/components/general/document/document-status';
|
||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/documents.f.$folderId.$id._index';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
let team: TGetTeamByUrlResponse | null = null;
|
||||
|
||||
if (params.teamUrl) {
|
||||
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||
}
|
||||
|
||||
const { id, folderId } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || !folderId) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
const document = await getDocumentById({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
folderId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (document?.teamId && !team?.url) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
const documentVisibility = document?.visibility;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||
let canAccessDocument = true;
|
||||
|
||||
if (team && !isRecipient && document?.userId !== user.id) {
|
||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||
.otherwise(() => false);
|
||||
}
|
||||
|
||||
if (!document || !document.documentData || (team && !canAccessDocument)) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
if (team && !canAccessDocument) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
if (document.folderId !== folderId) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
// Todo: Get full document instead?
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
teamId: team?.id,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const documentWithRecipients = {
|
||||
...document,
|
||||
recipients,
|
||||
};
|
||||
|
||||
return superLoaderJson({
|
||||
document: documentWithRecipients,
|
||||
documentRootPath,
|
||||
fields,
|
||||
folderId,
|
||||
});
|
||||
}
|
||||
|
||||
export default function DocumentPage() {
|
||||
const loaderData = useSuperLoaderData<typeof loader>();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { user } = useSession();
|
||||
|
||||
const { document, documentRootPath, fields, folderId } = loaderData;
|
||||
|
||||
const { recipients, documentData, documentMeta } = document;
|
||||
|
||||
// This was a feature flag. Leave to false since it's not ready.
|
||||
const isDocumentHistoryEnabled = false;
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
||||
)}
|
||||
|
||||
<Link
|
||||
to={folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={recipients}
|
||||
documentStatus={document.status}
|
||||
position="bottom"
|
||||
>
|
||||
<span>
|
||||
<Trans>{recipients.length} Recipient(s)</Trans>
|
||||
</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{document.deletedAt && (
|
||||
<Badge variant="destructive">
|
||||
<Trans>Document deleted</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDocumentHistoryEnabled && (
|
||||
<div className="self-end">
|
||||
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||
<Button variant="outline">
|
||||
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||
<Trans>Document history</Trans>
|
||||
</Button>
|
||||
</DocumentHistorySheet>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
|
||||
)}
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<div className="space-y-6">
|
||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||
<div className="flex flex-row items-center justify-between px-4">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
||||
</h3>
|
||||
|
||||
<DocumentPageViewDropdown document={document} />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||
{match(document.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<Trans>This document has been signed by all recipients</Trans>
|
||||
))
|
||||
.with(DocumentStatus.REJECTED, () => (
|
||||
<Trans>This document has been rejected by a recipient</Trans>
|
||||
))
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => {
|
||||
const pendingRecipients = recipients.filter(
|
||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||
);
|
||||
|
||||
return (
|
||||
<Plural
|
||||
value={pendingRecipients.length}
|
||||
one="Waiting on 1 recipient"
|
||||
other="Waiting on # recipients"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.exhaustive()}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 border-t px-4 pt-4">
|
||||
<DocumentPageViewButton document={document} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Document information section. */}
|
||||
<DocumentPageViewInformation document={document} userId={user.id} />
|
||||
|
||||
{/* Recipients section. */}
|
||||
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
|
||||
|
||||
{/* Recent activity section. */}
|
||||
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/documents.$id.edit';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
let team: TGetTeamByUrlResponse | null = null;
|
||||
|
||||
if (params.teamUrl) {
|
||||
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||
}
|
||||
|
||||
const { id, folderId } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (!folderId) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const document = await getDocumentWithDetailsById({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
folderId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (document?.teamId && !team?.url) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const documentVisibility = document?.visibility;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
||||
let canAccessDocument = true;
|
||||
|
||||
if (!isRecipient && document?.userId !== user.id) {
|
||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
||||
.otherwise(() => false);
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (team && !canAccessDocument) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (document.folderId !== folderId) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
if (isDocumentCompleted(document.status)) {
|
||||
throw redirect(`${documentRootPath}/${documentId}`);
|
||||
}
|
||||
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
document: {
|
||||
...document,
|
||||
folder: null,
|
||||
},
|
||||
documentRootPath,
|
||||
isDocumentEnterprise,
|
||||
folderId,
|
||||
});
|
||||
}
|
||||
|
||||
export default function DocumentEditPage() {
|
||||
const { document, documentRootPath, isDocumentEnterprise, folderId } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
|
||||
const { recipients } = document;
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link
|
||||
to={`${documentRootPath}/f/${folderId}`}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={recipients}
|
||||
documentStatus={document.status}
|
||||
position="bottom"
|
||||
>
|
||||
<span>
|
||||
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||
</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DocumentEditForm
|
||||
className="mt-6"
|
||||
initialDocument={document}
|
||||
documentRootPath={documentRootPath}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { Card } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { DocumentAuditLogDownloadButton } from '~/components/general/document/document-audit-log-download-button';
|
||||
import { DocumentCertificateDownloadButton } from '~/components/general/document/document-certificate-download-button';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
} from '~/components/general/document/document-status';
|
||||
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
|
||||
|
||||
import type { Route } from './+types/documents.f.$folderId.$id.logs';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
let team: TGetTeamByUrlResponse | null = null;
|
||||
|
||||
if (params.teamUrl) {
|
||||
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||
}
|
||||
|
||||
const { id, folderId } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
if (!folderId) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
// Todo: Get full document instead?
|
||||
const [document, recipients] = await Promise.all([
|
||||
getDocumentById({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
folderId,
|
||||
}).catch(() => null),
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
if (document.folderId !== folderId) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
return {
|
||||
document,
|
||||
documentRootPath,
|
||||
recipients,
|
||||
folderId,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { document, documentRootPath, recipients, folderId } = loaderData;
|
||||
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const documentInformation: { description: MessageDescriptor; value: string }[] = [
|
||||
{
|
||||
description: msg`Document title`,
|
||||
value: document.title,
|
||||
},
|
||||
{
|
||||
description: msg`Document ID`,
|
||||
value: document.id.toString(),
|
||||
},
|
||||
{
|
||||
description: msg`Document status`,
|
||||
value: _(FRIENDLY_STATUS_MAP[document.status].label),
|
||||
},
|
||||
{
|
||||
description: msg`Created by`,
|
||||
value: document.user.name
|
||||
? `${document.user.name} (${document.user.email})`
|
||||
: document.user.email,
|
||||
},
|
||||
{
|
||||
description: msg`Date created`,
|
||||
value: DateTime.fromJSDate(document.createdAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||
},
|
||||
{
|
||||
description: msg`Last updated`,
|
||||
value: DateTime.fromJSDate(document.updatedAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
|
||||
},
|
||||
{
|
||||
description: msg`Time zone`,
|
||||
value: document.documentMeta?.timezone ?? 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
const formatRecipientText = (recipient: Recipient) => {
|
||||
let text = recipient.email;
|
||||
|
||||
if (recipient.name) {
|
||||
text = `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
return `[${recipient.role}] ${text}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link
|
||||
to={`${documentRootPath}/f/${folderId}/${document.id}`}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Document</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col justify-between sm:flex-row">
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<DocumentCertificateDownloadButton
|
||||
className="mr-2"
|
||||
documentId={document.id}
|
||||
documentStatus={document.status}
|
||||
/>
|
||||
|
||||
<DocumentAuditLogDownloadButton documentId={document.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||
{documentInformation.map((info, i) => (
|
||||
<div className="text-foreground text-sm" key={i}>
|
||||
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||
<p className="text-muted-foreground truncate">{info.value}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-foreground text-sm">
|
||||
<h3 className="font-semibold">Recipients</h3>
|
||||
<ul className="text-muted-foreground list-inside list-disc">
|
||||
{recipients.map((recipient) => (
|
||||
<li key={`recipient-${recipient.id}`}>
|
||||
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<DocumentLogsTable documentId={document.id} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,374 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
type TFindDocumentsInternalResponse,
|
||||
ZFindDocumentsInternalRequestSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||
import { PeriodSelector } from '~/components/general/period-selector';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Documents');
|
||||
}
|
||||
|
||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
status: true,
|
||||
period: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
}).extend({
|
||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
});
|
||||
|
||||
const findDocumentSearchParams = useMemo(
|
||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
|
||||
{
|
||||
...findDocumentSearchParams,
|
||||
folderId,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: isFoldersLoading,
|
||||
refetch: refetchFolders,
|
||||
} = trpc.folder.getFolders.useQuery({
|
||||
parentId: folderId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
void refetchFolders();
|
||||
}, [team?.url]);
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
return `${formatDocumentsPath(team?.url)}/f/${folderId}?${params.toString()}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.stats) {
|
||||
setStats(data.stats);
|
||||
}
|
||||
}, [data?.stats]);
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (folderId) {
|
||||
void navigate(`${documentsPath}/f/${folderId}`);
|
||||
} else {
|
||||
void navigate(documentsPath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentDropZoneWrapper>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(null)}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
|
||||
{foldersData?.breadcrumbs.map((folder) => (
|
||||
<div key={folder.id} className="flex items-center space-x-2">
|
||||
<span>/</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(folder.id)}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
<DocumentUploadDropzone />
|
||||
<CreateFolderDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{foldersData?.folders && foldersData.folders.some((folder) => folder.pinned) && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData.folders
|
||||
.filter((folder) => folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={navigateToFolder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData?.folders
|
||||
.filter((folder) => !folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={navigateToFolder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data &&
|
||||
data.count === 0 &&
|
||||
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
|
||||
<DocumentsTableEmptyState
|
||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||
/>
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
onMoveDocument={(documentId) => {
|
||||
setDocumentToMove(documentId);
|
||||
setIsMovingDocument(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{documentToMove && (
|
||||
<DocumentMoveToFolderDialog
|
||||
documentId={documentToMove}
|
||||
open={isMovingDocument}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingDocument(open);
|
||||
|
||||
if (!open) {
|
||||
setDocumentToMove(null);
|
||||
}
|
||||
}}
|
||||
currentFolderId={folderId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderSettingsDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DocumentDropZoneWrapper>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Documents');
|
||||
}
|
||||
|
||||
export default function DocumentsFoldersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
|
||||
type: FolderType.DOCUMENT,
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const documentsPath = formatDocumentsPath();
|
||||
|
||||
if (folderId) {
|
||||
void navigate(`${documentsPath}/f/${folderId}`);
|
||||
} else {
|
||||
void navigate(documentsPath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(null)}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
|
||||
<CreateFolderDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{foldersData?.folders?.some((folder) => folder.pinned) && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData.folders
|
||||
.filter((folder) => folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={navigateToFolder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12">
|
||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>All Folders</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData?.folders
|
||||
.filter((folder) => !folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={navigateToFolder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderSettingsDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id._index';
|
||||
|
||||
export { loader };
|
||||
|
||||
export default DocumentPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id.edit';
|
||||
|
||||
export { loader };
|
||||
|
||||
export default DocumentEditPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id.logs';
|
||||
|
||||
export { loader };
|
||||
|
||||
export default DocumentLogsPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents.f.$folderId._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default DocumentsPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentsFoldersPage, { meta } from '~/routes/_authenticated+/documents.folders._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default DocumentsFoldersPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import TemplatePage, { loader } from '~/routes/_authenticated+/templates.f.$folderId.$id._index';
|
||||
|
||||
export { loader };
|
||||
|
||||
export default TemplatePage;
|
||||
@ -0,0 +1,5 @@
|
||||
import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates.f.$folderId.$id.edit';
|
||||
|
||||
export { loader };
|
||||
|
||||
export default TemplateEditPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import TemplatesPage, { meta } from '~/routes/_authenticated+/templates.f.$folderId._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default TemplatesPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import TemplatesFoldersPage, { meta } from '~/routes/_authenticated+/templates.folders._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default TemplatesFoldersPage;
|
||||
@ -55,6 +55,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw redirect(templateRootPath);
|
||||
}
|
||||
|
||||
if (template.folderId) {
|
||||
throw redirect(`${templateRootPath}/f/${template.folderId}/${templateId}`);
|
||||
}
|
||||
|
||||
return superLoaderJson({
|
||||
user,
|
||||
team,
|
||||
|
||||
@ -45,13 +45,20 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw redirect(templateRootPath);
|
||||
}
|
||||
|
||||
if (template.folderId) {
|
||||
throw redirect(`${templateRootPath}/f/${template.folderId}/${templateId}/edit`);
|
||||
}
|
||||
|
||||
const isTemplateEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
template,
|
||||
template: {
|
||||
...template,
|
||||
folder: null,
|
||||
},
|
||||
isTemplateEnterprise,
|
||||
templateRootPath,
|
||||
});
|
||||
@ -65,7 +72,11 @@ export default function TemplateEditPage() {
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<Link
|
||||
to={`${templateRootPath}/${template.id}`}
|
||||
to={
|
||||
template.folderId
|
||||
? `${templateRootPath}/f/${template.folderId}/${template.id}`
|
||||
: `${templateRootPath}/${template.id}`
|
||||
}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Bird, FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||
import { TemplateFolderCreateDialog } from '~/components/dialogs/template-folder-create-dialog';
|
||||
import { TemplateFolderDeleteDialog } from '~/components/dialogs/template-folder-delete-dialog';
|
||||
import { TemplateFolderMoveDialog } from '~/components/dialogs/template-folder-move-dialog';
|
||||
import { TemplateFolderSettingsDialog } from '~/components/dialogs/template-folder-settings-dialog';
|
||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@ -19,10 +27,21 @@ export function meta() {
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
@ -34,19 +53,165 @@ export default function TemplatesPage() {
|
||||
perPage: perPage,
|
||||
});
|
||||
|
||||
// Refetch the templates when the team URL changes.
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: isFoldersLoading,
|
||||
refetch: refetchFolders,
|
||||
} = trpc.folder.getFolders.useQuery({
|
||||
type: FolderType.TEMPLATE,
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
void refetchFolders();
|
||||
}, [team?.url]);
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const templatesPath = formatTemplatesPath(team?.url);
|
||||
|
||||
if (folderId) {
|
||||
void navigate(`${templatesPath}/f/${folderId}`);
|
||||
} else {
|
||||
void navigate(templatesPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (folderId: string) => {
|
||||
navigateToFolder(folderId);
|
||||
};
|
||||
|
||||
const handleMove = (folder: TFolderWithSubfolders) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
};
|
||||
|
||||
const handlePin = (folderId: string) => {
|
||||
void pinFolder({ folderId });
|
||||
};
|
||||
|
||||
const handleUnpin = (folderId: string) => {
|
||||
void unpinFolder({ folderId });
|
||||
};
|
||||
|
||||
const handleSettings = (folder: TFolderWithSubfolders) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (folder: TFolderWithSubfolders) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
};
|
||||
|
||||
const handleViewAllFolders = () => {
|
||||
void navigate(`${formatTemplatesPath(team?.url)}/folders`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(null)}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
|
||||
{foldersData?.breadcrumbs.map((folder) => (
|
||||
<div key={folder.id} className="flex items-center space-x-2">
|
||||
<span>/</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(folder.id)}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
<TemplateCreateDialog templateRootPath={templateRootPath} />
|
||||
<TemplateFolderCreateDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{foldersData?.folders && foldersData.folders.some((folder) => folder.pinned) && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData.folders
|
||||
.filter((folder) => folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={handleNavigate}
|
||||
onMove={handleMove}
|
||||
onPin={handlePin}
|
||||
onUnpin={handleUnpin}
|
||||
onSettings={handleSettings}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData?.folders
|
||||
?.filter((folder) => !folder.pinned)
|
||||
.slice(0, 12)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={handleNavigate}
|
||||
onMove={handleMove}
|
||||
onPin={handlePin}
|
||||
onUnpin={handleUnpin}
|
||||
onSettings={handleSettings}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center">
|
||||
{foldersData && foldersData.folders?.length > 12 && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void handleViewAllFolders()}
|
||||
>
|
||||
View all folders
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-12">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@ -57,38 +222,71 @@ export default function TemplatesPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TemplateCreateDialog templateRootPath={templateRootPath} teamId={team?.id} />
|
||||
<div className="mt-8">
|
||||
{data && data.count === 0 ? (
|
||||
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>We're all empty</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 max-w-[50ch]">
|
||||
<Trans>
|
||||
You have not yet created any templates. To create a template please upload one.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TemplatesTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-5">
|
||||
{data && data.count === 0 ? (
|
||||
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
<TemplateFolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>We're all empty</Trans>
|
||||
</h3>
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-2 max-w-[50ch]">
|
||||
<Trans>
|
||||
You have not yet created any templates. To create a template please upload one.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TemplatesTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TemplateFolderSettingsDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TemplateFolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,230 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
|
||||
import { ChevronLeft, LucideEdit } from 'lucide-react';
|
||||
import { Link, redirect, useNavigate } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
||||
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
|
||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
||||
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
|
||||
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
|
||||
import { TemplatePageViewRecentActivity } from '~/components/general/template/template-page-view-recent-activity';
|
||||
import { TemplatePageViewRecipients } from '~/components/general/template/template-page-view-recipients';
|
||||
import { TemplateType } from '~/components/general/template/template-type';
|
||||
import { TemplatesTableActionDropdown } from '~/components/tables/templates-table-action-dropdown';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/templates.$id._index';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
let team: TGetTeamByUrlResponse | null = null;
|
||||
|
||||
if (params.teamUrl) {
|
||||
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||
}
|
||||
|
||||
const { id, folderId } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
throw redirect(folderId ? `${documentRootPath}/f/${folderId}` : documentRootPath);
|
||||
}
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
folderId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
|
||||
throw redirect(folderId ? `${templateRootPath}/f/${folderId}` : templateRootPath);
|
||||
}
|
||||
|
||||
if (!template.folderId) {
|
||||
throw redirect(`${templateRootPath}/${templateId}`);
|
||||
}
|
||||
|
||||
if (template.folderId !== folderId) {
|
||||
throw redirect(`${templateRootPath}/f/${template.folderId}/${templateId}`);
|
||||
}
|
||||
|
||||
return superLoaderJson({
|
||||
user,
|
||||
team,
|
||||
template,
|
||||
templateRootPath,
|
||||
documentRootPath,
|
||||
});
|
||||
}
|
||||
|
||||
export default function TemplatePage() {
|
||||
const { user, team, template, templateRootPath, documentRootPath } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
|
||||
const { templateDocumentData, fields, recipients, templateMeta } = template;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Remap to fit the DocumentReadOnlyFields component.
|
||||
const readOnlyFields = fields.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
|
||||
name: '',
|
||||
email: '',
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
};
|
||||
|
||||
return {
|
||||
...field,
|
||||
recipient,
|
||||
signature: null,
|
||||
};
|
||||
});
|
||||
|
||||
const mockedDocumentMeta = templateMeta
|
||||
? {
|
||||
...templateMeta,
|
||||
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
||||
documentId: 0,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Templates</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center">
|
||||
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
|
||||
|
||||
{template.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-4"
|
||||
token={template.directLink.token}
|
||||
enabled={template.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
|
||||
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
|
||||
|
||||
<Button className="w-full" asChild>
|
||||
<Link to={`${templateRootPath}/${template.id}/edit`}>
|
||||
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||
<Trans>Edit Template</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer document={template} key={template.id} documentData={templateDocumentData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DocumentReadOnlyFields
|
||||
fields={readOnlyFields}
|
||||
showFieldStatus={false}
|
||||
documentMeta={mockedDocumentMeta}
|
||||
/>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<div className="space-y-6">
|
||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||
<div className="flex flex-row items-center justify-between px-4">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Template</Trans>
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<TemplatesTableActionDropdown
|
||||
row={template}
|
||||
teamId={team?.id}
|
||||
templateRootPath={templateRootPath}
|
||||
onDelete={async () => navigate(templateRootPath)}
|
||||
onMove={async ({ teamUrl, templateId }) =>
|
||||
navigate(`${formatTemplatesPath(teamUrl)}/${templateId}`)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||
<Trans>Manage and view template</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 border-t px-4 pt-4">
|
||||
<TemplateUseDialog
|
||||
templateId={template.id}
|
||||
templateSigningOrder={template.templateMeta?.signingOrder}
|
||||
recipients={template.recipients}
|
||||
documentRootPath={documentRootPath}
|
||||
trigger={
|
||||
<Button className="w-full">
|
||||
<Trans>Use</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Template information section. */}
|
||||
<TemplatePageViewInformation template={template} userId={user.id} />
|
||||
|
||||
{/* Recipients section. */}
|
||||
<TemplatePageViewRecipients template={template} templateRootPath={templateRootPath} />
|
||||
|
||||
{/* Recent activity section. */}
|
||||
<TemplatePageViewRecentActivity
|
||||
documentRootPath={documentRootPath}
|
||||
templateId={template.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16" id="documents">
|
||||
<h1 className="mb-4 text-2xl font-bold">
|
||||
<Trans>Documents created from template</Trans>
|
||||
</h1>
|
||||
|
||||
<TemplatePageViewDocumentsTable templateId={template.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
||||
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
|
||||
import { TemplateType } from '~/components/general/template/template-type';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import { TemplateDirectLinkDialogWrapper } from '../../components/dialogs/template-direct-link-dialog-wrapper';
|
||||
import type { Route } from './+types/templates.$id.edit';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
let team: TGetTeamByUrlResponse | null = null;
|
||||
|
||||
if (params.teamUrl) {
|
||||
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
throw redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
throw redirect(templateRootPath);
|
||||
}
|
||||
|
||||
if (!template.folderId) {
|
||||
throw redirect(`${templateRootPath}/${templateId}/edit`);
|
||||
}
|
||||
|
||||
if (template.folderId !== params.folderId) {
|
||||
throw redirect(`${templateRootPath}/f/${template.folderId}/${templateId}/edit`);
|
||||
}
|
||||
|
||||
const isTemplateEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
template: {
|
||||
...template,
|
||||
folder: null,
|
||||
},
|
||||
isTemplateEnterprise,
|
||||
templateRootPath,
|
||||
});
|
||||
}
|
||||
|
||||
export default function TemplateEditPage() {
|
||||
const { template, isTemplateEnterprise, templateRootPath } = useSuperLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<Link
|
||||
to={
|
||||
template.folderId
|
||||
? `${templateRootPath}/f/${template.folderId}/${template.id}`
|
||||
: `${templateRootPath}/${template.id}`
|
||||
}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Template</Trans>
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center">
|
||||
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
|
||||
|
||||
{template.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-4"
|
||||
token={template.directLink.token}
|
||||
enabled={template.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TemplateEditForm
|
||||
className="mt-6"
|
||||
initialTemplate={template}
|
||||
templateRootPath={templateRootPath}
|
||||
isEnterprise={isTemplateEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,369 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Bird, FolderIcon, HomeIcon, Loader2, PinIcon } from 'lucide-react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||
import { TemplateFolderCreateDialog } from '~/components/dialogs/template-folder-create-dialog';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Templates');
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { folderId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery({
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
folderId: folderId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: isFoldersLoading,
|
||||
refetch: refetchFolders,
|
||||
} = trpc.folder.getFolders.useQuery({
|
||||
parentId: folderId,
|
||||
type: FolderType.TEMPLATE,
|
||||
});
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
void refetchFolders();
|
||||
}, [team?.url]);
|
||||
|
||||
const navigateToFolder = (folderId?: string) => {
|
||||
const templatesPath = formatTemplatesPath(team?.url);
|
||||
|
||||
if (folderId) {
|
||||
void navigate(`${templatesPath}/f/${folderId}`);
|
||||
} else {
|
||||
void navigate(templatesPath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder()}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
|
||||
{foldersData?.breadcrumbs.map((folder) => (
|
||||
<div key={folder.id} className="flex items-center space-x-2">
|
||||
<span>/</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(folder.id)}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
<TemplateFolderCreateDialog />
|
||||
<TemplateCreateDialog templateRootPath={templateRootPath} folderId={folderId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{foldersData?.folders.some((folder) => folder.pinned) && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData?.folders
|
||||
.filter((folder) => folder.pinned)
|
||||
.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<button
|
||||
className="flex items-center space-x-2 text-left"
|
||||
onClick={() => navigateToFolder(folder.id)}
|
||||
>
|
||||
<FolderIcon className="text-documenso h-6 w-6" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{folder.name}</h3>
|
||||
<PinIcon className="text-documenso h-3 w-3" />
|
||||
</div>
|
||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
||||
<span>{folder._count.templates || 0} templates</span>
|
||||
<span>•</span>
|
||||
<span>{folder._count.subfolders} folders</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void unpinFolder({ folderId: folder.id });
|
||||
}}
|
||||
>
|
||||
Unpin
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-500"
|
||||
onClick={() => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData?.folders
|
||||
.filter((folder) => !folder.pinned)
|
||||
.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<button
|
||||
className="flex items-center space-x-2 text-left"
|
||||
onClick={() => navigateToFolder(folder.id)}
|
||||
>
|
||||
<FolderIcon className="text-documenso h-6 w-6" />
|
||||
<div>
|
||||
<h3 className="font-medium">{folder.name}</h3>
|
||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
||||
<span>{folder._count.templates || 0} templates</span>
|
||||
<span>•</span>
|
||||
<span>{folder._count.subfolders} folders</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void pinFolder({ folderId: folder.id });
|
||||
}}
|
||||
>
|
||||
Pin
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-500"
|
||||
onClick={() => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative mt-12">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Templates</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{data && data.count === 0 ? (
|
||||
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>We're all empty</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 max-w-[50ch]">
|
||||
<Trans>
|
||||
You have not yet created any templates. To create a template please upload one.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TemplatesTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderSettingsDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateFolderCreateDialog } from '~/components/dialogs/template-folder-create-dialog';
|
||||
import { TemplateFolderDeleteDialog } from '~/components/dialogs/template-folder-delete-dialog';
|
||||
import { TemplateFolderMoveDialog } from '~/components/dialogs/template-folder-move-dialog';
|
||||
import { TemplateFolderSettingsDialog } from '~/components/dialogs/template-folder-settings-dialog';
|
||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Templates');
|
||||
}
|
||||
|
||||
export default function TemplatesFoldersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
|
||||
type: FolderType.TEMPLATE,
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const templatesPath = formatTemplatesPath();
|
||||
|
||||
if (folderId) {
|
||||
void navigate(`${templatesPath}/f/${folderId}`);
|
||||
} else {
|
||||
void navigate(templatesPath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(null)}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
|
||||
<TemplateFolderCreateDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt- flex justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{foldersData?.folders?.some((folder) => folder.pinned) && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData.folders
|
||||
.filter((folder) => folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={navigateToFolder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12">
|
||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>All Folders</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData?.folders
|
||||
.filter((folder) => !folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onNavigate={navigateToFolder}
|
||||
onMove={(folder) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
}}
|
||||
onDelete={(folder) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TemplateFolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TemplateFolderSettingsDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TemplateFolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,8 +5,10 @@ import { DateTime } from 'luxon';
|
||||
import { redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { renderSVG } from 'uqr';
|
||||
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
@ -342,7 +344,18 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</Card>
|
||||
|
||||
{isPlatformDocument && (
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="my-8 flex-row-reverse space-y-4">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<div
|
||||
className="flex h-24 w-24 justify-center"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${document.qrToken}`, {
|
||||
ecc: 'Q',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||
{_(msg`Signing certificate provided by`)}:
|
||||
|
||||
@ -60,7 +60,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
||||
return superLoaderJson({
|
||||
isAccessAuthValid: true,
|
||||
template,
|
||||
template: {
|
||||
...template,
|
||||
folder: null,
|
||||
},
|
||||
directTemplateRecipient,
|
||||
} as const);
|
||||
}
|
||||
|
||||
11
apps/remix/app/routes/_share+/_layout.tsx
Normal file
11
apps/remix/app/routes/_share+/_layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -22,6 +22,11 @@ const IMAGE_SIZE = {
|
||||
export const loader = async ({ params }: Route.LoaderArgs) => {
|
||||
const { slug } = params;
|
||||
|
||||
// QR codes are not supported for OpenGraph images
|
||||
if (slug.startsWith('qr_')) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const baseUrl = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
const [interSemiBold, interRegular, caveatRegular] = await Promise.all([
|
||||
@ -1,10 +1,17 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { redirect, useLoaderData } from 'react-router';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getDocumentByAccessToken } from '@documenso/lib/server-only/document/get-document-by-access-token';
|
||||
|
||||
import { DocumentCertificateQRView } from '~/components/general/document/document-certificate-qr-view';
|
||||
|
||||
import type { Route } from './+types/share.$slug';
|
||||
|
||||
export function meta({ params: { slug } }: Route.MetaArgs) {
|
||||
if (slug.startsWith('qr_')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
{ title: 'Documenso - Share' },
|
||||
{ description: 'I just signed a document in style with Documenso!' },
|
||||
@ -43,11 +50,23 @@ export function meta({ params: { slug } }: Route.MetaArgs) {
|
||||
];
|
||||
}
|
||||
|
||||
export const loader = ({ request }: Route.LoaderArgs) => {
|
||||
export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) => {
|
||||
if (slug.startsWith('qr_')) {
|
||||
const document = await getDocumentByAccessToken({ token: slug });
|
||||
|
||||
if (!document) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
return {
|
||||
document,
|
||||
};
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get('User-Agent') ?? '';
|
||||
|
||||
if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) {
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
|
||||
// Is hardcoded because this whole meta is hardcoded anyway for Documenso.
|
||||
@ -55,5 +74,20 @@ export const loader = ({ request }: Route.LoaderArgs) => {
|
||||
};
|
||||
|
||||
export default function SharePage() {
|
||||
const { document } = useLoaderData<typeof loader>();
|
||||
|
||||
if (document) {
|
||||
return (
|
||||
<DocumentCertificateQRView
|
||||
documentId={document.id}
|
||||
title={document.title}
|
||||
documentData={document.documentData}
|
||||
password={document.documentMeta?.password}
|
||||
recipientCount={document.recipients?.length ?? 0}
|
||||
completedDate={document.completedAt ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div></div>;
|
||||
}
|
||||
@ -24,36 +24,27 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
console.log('########################');
|
||||
console.log('########################');
|
||||
console.log('########################');
|
||||
console.log('########################');
|
||||
console.log('########################');
|
||||
|
||||
if (!settings || !settings.brandingEnabled) {
|
||||
console.log('----------------------------');
|
||||
if (!settings || !settings.brandingLogo) {
|
||||
return Response.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
message: 'Logo not found',
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings.brandingLogo) {
|
||||
console.log('!!!!!!!!!!!!!!!!!!!!!!!!');
|
||||
if (!settings.brandingEnabled) {
|
||||
return Response.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
message: 'Branding is not enabled',
|
||||
},
|
||||
{ status: 404 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => {
|
||||
console.log('@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@');
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
|
||||
102
apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx
Normal file
102
apps/remix/app/routes/embed+/v1+/authoring+/_layout.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { Outlet, useLoaderData } from 'react-router';
|
||||
|
||||
import { isCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
|
||||
import { ZBaseEmbedAuthoringSchema } from '~/types/embed-authoring-base-schema';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
hasValidToken: false,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||
|
||||
let hasPlatformPlan = false;
|
||||
let hasEnterprisePlan = false;
|
||||
let hasCommunityPlan = false;
|
||||
|
||||
if (result) {
|
||||
[hasCommunityPlan, hasPlatformPlan, hasEnterprisePlan] = await Promise.all([
|
||||
isCommunityPlan({
|
||||
userId: result.userId,
|
||||
teamId: result.teamId ?? undefined,
|
||||
}),
|
||||
isDocumentPlatform({
|
||||
userId: result.userId,
|
||||
teamId: result.teamId,
|
||||
}),
|
||||
isUserEnterprise({
|
||||
userId: result.userId,
|
||||
teamId: result.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
hasValidToken: !!result,
|
||||
token,
|
||||
hasCommunityPlan,
|
||||
hasPlatformPlan,
|
||||
hasEnterprisePlan,
|
||||
};
|
||||
};
|
||||
|
||||
export default function AuthoringLayout() {
|
||||
const { hasValidToken, token, hasCommunityPlan, hasPlatformPlan, hasEnterprisePlan } =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
try {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||
JSON.parse(decodeURIComponent(atob(hash))),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { css, cssVars, darkModeDisabled } = result.data;
|
||||
|
||||
if (darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (hasCommunityPlan || hasPlatformPlan || hasEnterprisePlan) {
|
||||
injectCss({
|
||||
css,
|
||||
cssVars,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!hasValidToken) {
|
||||
return <div>Invalid embedding presign token provided</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TrpcProvider headers={{ authorization: `Bearer ${token}` }}>
|
||||
<Outlet />
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
@ -12,10 +12,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
|
||||
import {
|
||||
ConfigureFieldsView,
|
||||
type TConfigureFieldsFormSchema,
|
||||
} from '~/components/embed/authoring/configure-fields-view';
|
||||
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
|
||||
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
|
||||
import {
|
||||
type TBaseEmbedAuthoringSchema,
|
||||
ZBaseEmbedAuthoringSchema,
|
||||
@ -71,6 +69,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
// Use the externalId from the URL fragment if available
|
||||
const documentExternalId = externalId || configuration.meta.externalId;
|
||||
|
||||
const signatureTypes = configuration.meta.signatureTypes ?? [];
|
||||
|
||||
const createResult = await createEmbeddingDocument({
|
||||
title: configuration.title,
|
||||
documentDataId: documentData.id,
|
||||
@ -78,14 +78,11 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
meta: {
|
||||
...configuration.meta,
|
||||
drawSignatureEnabled:
|
||||
configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled:
|
||||
configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled:
|
||||
configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
name: signer.name,
|
||||
@ -126,7 +123,7 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
|
||||
// Navigate to the completion page instead of the document details page
|
||||
await navigate(
|
||||
`/embed/v1/authoring/create-completed?documentId=${createResult.documentId}&externalId=${documentExternalId}#${hash}`,
|
||||
`/embed/v1/authoring/completed/create?documentId=${createResult.documentId}&externalId=${documentExternalId}#${hash}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error creating document:', err);
|
||||
@ -0,0 +1,314 @@
|
||||
import { useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder, SigningStatus } from '@prisma/client';
|
||||
import { redirect, useLoaderData } from 'react-router';
|
||||
|
||||
import {
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
isValidDateFormat,
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
|
||||
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
|
||||
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
|
||||
import {
|
||||
type TBaseEmbedAuthoringSchema,
|
||||
ZBaseEmbedAuthoringSchema,
|
||||
} from '~/types/embed-authoring-base-schema';
|
||||
|
||||
import type { Route } from './+types/document.edit.$id';
|
||||
|
||||
export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const { id } = params;
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// We know that the token is present because we're checking it in the parent _layout route
|
||||
const token = url.searchParams.get('token') || '';
|
||||
|
||||
// We also know that the token is valid, but we need the userId + teamId
|
||||
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect(`/embed/v1/authoring/error/not-found?documentId=${documentId}`);
|
||||
}
|
||||
|
||||
const document = await getDocumentWithDetailsById({
|
||||
documentId,
|
||||
userId: result?.userId,
|
||||
teamId: result?.teamId ?? undefined,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
throw redirect(`/embed/v1/authoring/error/not-found?documentId=${documentId}`);
|
||||
}
|
||||
|
||||
const fields = document.fields.map((field) => ({
|
||||
...field,
|
||||
positionX: field.positionX.toNumber(),
|
||||
positionY: field.positionY.toNumber(),
|
||||
width: field.width.toNumber(),
|
||||
height: field.height.toNumber(),
|
||||
}));
|
||||
|
||||
return {
|
||||
document: {
|
||||
...document,
|
||||
fields,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function EmbeddingAuthoringDocumentEditPage() {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { document } = useLoaderData<typeof loader>();
|
||||
|
||||
const signatureTypes = useMemo(() => {
|
||||
const types: string[] = [];
|
||||
|
||||
if (document.documentMeta?.drawSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.DRAW);
|
||||
}
|
||||
|
||||
if (document.documentMeta?.typedSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.TYPE);
|
||||
}
|
||||
|
||||
if (document.documentMeta?.uploadSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.UPLOAD);
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [document.documentMeta]);
|
||||
|
||||
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(() => ({
|
||||
title: document.title,
|
||||
documentData: undefined,
|
||||
meta: {
|
||||
subject: document.documentMeta?.subject ?? undefined,
|
||||
message: document.documentMeta?.message ?? undefined,
|
||||
distributionMethod:
|
||||
document.documentMeta?.distributionMethod ?? DocumentDistributionMethod.EMAIL,
|
||||
emailSettings: document.documentMeta?.emailSettings ?? ZDocumentEmailSettingsSchema.parse({}),
|
||||
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
signingOrder: document.documentMeta?.signingOrder ?? DocumentSigningOrder.PARALLEL,
|
||||
allowDictateNextSigner: document.documentMeta?.allowDictateNextSigner ?? false,
|
||||
language: isValidLanguageCode(document.documentMeta?.language)
|
||||
? document.documentMeta.language
|
||||
: undefined,
|
||||
signatureTypes: signatureTypes,
|
||||
dateFormat: isValidDateFormat(document.documentMeta?.dateFormat)
|
||||
? document.documentMeta?.dateFormat
|
||||
: DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
redirectUrl: document.documentMeta?.redirectUrl ?? undefined,
|
||||
},
|
||||
signers: document.recipients.map((recipient) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: nanoid(8),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder ?? undefined,
|
||||
disabled: recipient.signingStatus !== SigningStatus.NOT_SIGNED,
|
||||
})),
|
||||
}));
|
||||
|
||||
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(() => ({
|
||||
fields: document.fields.map((field) => ({
|
||||
nativeId: field.id,
|
||||
formId: nanoid(8),
|
||||
type: field.type,
|
||||
signerEmail:
|
||||
document.recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
inserted: field.inserted,
|
||||
recipientId: field.recipientId,
|
||||
pageNumber: field.page,
|
||||
pageX: field.positionX,
|
||||
pageY: field.positionY,
|
||||
pageWidth: field.width,
|
||||
pageHeight: field.height,
|
||||
fieldMeta: field.fieldMeta ?? undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
|
||||
const [externalId, setExternalId] = useState<string | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const { mutateAsync: updateEmbeddingDocument } =
|
||||
trpc.embeddingPresign.updateEmbeddingDocument.useMutation();
|
||||
|
||||
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
|
||||
// Store the configuration data and move to the field placement stage
|
||||
setConfiguration(data);
|
||||
setFields((fieldData) => {
|
||||
if (!fieldData) {
|
||||
return fieldData;
|
||||
}
|
||||
|
||||
const signerEmails = data.signers.map((signer) => signer.email);
|
||||
|
||||
return {
|
||||
fields: fieldData.fields.filter((field) => signerEmails.includes(field.signerEmail)),
|
||||
};
|
||||
});
|
||||
setCurrentStep(2);
|
||||
};
|
||||
|
||||
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
|
||||
// Return to the configuration view but keep the data
|
||||
setFields(data);
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
|
||||
try {
|
||||
if (!configuration) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Please configure the document first`),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = data.fields;
|
||||
|
||||
// Use the externalId from the URL fragment if available
|
||||
const documentExternalId = externalId || configuration.meta.externalId;
|
||||
|
||||
const updateResult = await updateEmbeddingDocument({
|
||||
documentId: document.id,
|
||||
title: configuration.title,
|
||||
externalId: documentExternalId,
|
||||
meta: {
|
||||
...configuration.meta,
|
||||
drawSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW)
|
||||
: undefined,
|
||||
typedSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE)
|
||||
: undefined,
|
||||
uploadSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||
: undefined,
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
name: signer.name,
|
||||
email: signer.email,
|
||||
role: signer.role,
|
||||
signingOrder: signer.signingOrder,
|
||||
fields: fields
|
||||
.filter((field) => field.signerEmail === signer.email)
|
||||
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map<any>((f) => ({
|
||||
...f,
|
||||
id: f.nativeId,
|
||||
pageX: f.pageX,
|
||||
pageY: f.pageY,
|
||||
width: f.pageWidth,
|
||||
height: f.pageHeight,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Document updated successfully`),
|
||||
});
|
||||
|
||||
// Send a message to the parent window with the document details
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'document-updated',
|
||||
documentId: updateResult.documentId,
|
||||
externalId: documentExternalId,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating document:', err);
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to update document`),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
try {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||
JSON.parse(decodeURIComponent(atob(hash))),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeatures(result.data.features);
|
||||
|
||||
// Extract externalId from the parsed data if available
|
||||
if (result.data.externalId) {
|
||||
setExternalId(result.data.externalId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing embedding params:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
|
||||
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
|
||||
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
|
||||
<ConfigureDocumentView
|
||||
defaultValues={configuration ?? undefined}
|
||||
disableUpload={true}
|
||||
onSubmit={handleConfigurePageViewSubmit}
|
||||
/>
|
||||
|
||||
<ConfigureFieldsView
|
||||
configData={configuration!}
|
||||
documentData={document.documentData}
|
||||
defaultValues={fields ?? undefined}
|
||||
onBack={handleBackToConfig}
|
||||
onSubmit={handleConfigureFieldsSubmit}
|
||||
/>
|
||||
</Stepper>
|
||||
</ConfigureDocumentProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,10 +11,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
|
||||
import {
|
||||
ConfigureFieldsView,
|
||||
type TConfigureFieldsFormSchema,
|
||||
} from '~/components/embed/authoring/configure-fields-view';
|
||||
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
|
||||
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
|
||||
import {
|
||||
type TBaseEmbedAuthoringSchema,
|
||||
ZBaseEmbedAuthoringSchema,
|
||||
@ -48,8 +46,6 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||
|
||||
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
|
||||
try {
|
||||
console.log('configuration', configuration);
|
||||
console.log('data', data);
|
||||
if (!configuration || !configuration.documentData) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@ -117,7 +113,7 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||
|
||||
// Navigate to the completion page instead of the template details page
|
||||
await navigate(
|
||||
`/embed/v1/authoring/create-completed?templateId=${createResult.templateId}&externalId=${metaWithExternalId.externalId}#${hash}`,
|
||||
`/embed/v1/authoring/completed/create?templateId=${createResult.templateId}&externalId=${metaWithExternalId.externalId}#${hash}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error creating template:', err);
|
||||
@ -0,0 +1,314 @@
|
||||
import { useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder, SigningStatus } from '@prisma/client';
|
||||
import { redirect, useLoaderData } from 'react-router';
|
||||
|
||||
import {
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
isValidDateFormat,
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
|
||||
import { ConfigureFieldsView } from '~/components/embed/authoring/configure-fields-view';
|
||||
import type { TConfigureFieldsFormSchema } from '~/components/embed/authoring/configure-fields-view.types';
|
||||
import {
|
||||
type TBaseEmbedAuthoringSchema,
|
||||
ZBaseEmbedAuthoringSchema,
|
||||
} from '~/types/embed-authoring-base-schema';
|
||||
|
||||
import type { Route } from './+types/document.edit.$id';
|
||||
|
||||
export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const { id } = params;
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// We know that the token is present because we're checking it in the parent _layout route
|
||||
const token = url.searchParams.get('token') || '';
|
||||
|
||||
// We also know that the token is valid, but we need the userId + teamId
|
||||
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const templateId = Number(id);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect(`/embed/v1/authoring/error/not-found?templateId=${templateId}`);
|
||||
}
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: result?.userId,
|
||||
teamId: result?.teamId ?? undefined,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template) {
|
||||
throw redirect(`/embed/v1/authoring/error/not-found?templateId=${templateId}`);
|
||||
}
|
||||
|
||||
const fields = template.fields.map((field) => ({
|
||||
...field,
|
||||
positionX: field.positionX.toNumber(),
|
||||
positionY: field.positionY.toNumber(),
|
||||
width: field.width.toNumber(),
|
||||
height: field.height.toNumber(),
|
||||
}));
|
||||
|
||||
return {
|
||||
template: {
|
||||
...template,
|
||||
fields,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function EmbeddingAuthoringTemplateEditPage() {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { template } = useLoaderData<typeof loader>();
|
||||
|
||||
const signatureTypes = useMemo(() => {
|
||||
const types: string[] = [];
|
||||
|
||||
if (template.templateMeta?.drawSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.DRAW);
|
||||
}
|
||||
|
||||
if (template.templateMeta?.typedSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.TYPE);
|
||||
}
|
||||
|
||||
if (template.templateMeta?.uploadSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.UPLOAD);
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [template.templateMeta]);
|
||||
|
||||
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(() => ({
|
||||
title: template.title,
|
||||
documentData: undefined,
|
||||
meta: {
|
||||
subject: template.templateMeta?.subject ?? undefined,
|
||||
message: template.templateMeta?.message ?? undefined,
|
||||
distributionMethod:
|
||||
template.templateMeta?.distributionMethod ?? DocumentDistributionMethod.EMAIL,
|
||||
emailSettings: template.templateMeta?.emailSettings ?? ZDocumentEmailSettingsSchema.parse({}),
|
||||
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
signingOrder: template.templateMeta?.signingOrder ?? DocumentSigningOrder.PARALLEL,
|
||||
allowDictateNextSigner: template.templateMeta?.allowDictateNextSigner ?? false,
|
||||
language: isValidLanguageCode(template.templateMeta?.language)
|
||||
? template.templateMeta.language
|
||||
: undefined,
|
||||
signatureTypes: signatureTypes,
|
||||
dateFormat: isValidDateFormat(template.templateMeta?.dateFormat)
|
||||
? template.templateMeta?.dateFormat
|
||||
: DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
redirectUrl: template.templateMeta?.redirectUrl ?? undefined,
|
||||
},
|
||||
signers: template.recipients.map((recipient) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: nanoid(8),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder ?? undefined,
|
||||
disabled: recipient.signingStatus !== SigningStatus.NOT_SIGNED,
|
||||
})),
|
||||
}));
|
||||
|
||||
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(() => ({
|
||||
fields: template.fields.map((field) => ({
|
||||
nativeId: field.id,
|
||||
formId: nanoid(8),
|
||||
type: field.type,
|
||||
signerEmail:
|
||||
template.recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
inserted: field.inserted,
|
||||
recipientId: field.recipientId,
|
||||
pageNumber: field.page,
|
||||
pageX: field.positionX,
|
||||
pageY: field.positionY,
|
||||
pageWidth: field.width,
|
||||
pageHeight: field.height,
|
||||
fieldMeta: field.fieldMeta ?? undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
|
||||
const [externalId, setExternalId] = useState<string | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const { mutateAsync: updateEmbeddingTemplate } =
|
||||
trpc.embeddingPresign.updateEmbeddingTemplate.useMutation();
|
||||
|
||||
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
|
||||
// Store the configuration data and move to the field placement stage
|
||||
setConfiguration(data);
|
||||
setFields((fieldData) => {
|
||||
if (!fieldData) {
|
||||
return fieldData;
|
||||
}
|
||||
|
||||
const signerEmails = data.signers.map((signer) => signer.email);
|
||||
|
||||
return {
|
||||
fields: fieldData.fields.filter((field) => signerEmails.includes(field.signerEmail)),
|
||||
};
|
||||
});
|
||||
setCurrentStep(2);
|
||||
};
|
||||
|
||||
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
|
||||
// Return to the configuration view but keep the data
|
||||
setFields(data);
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
|
||||
try {
|
||||
if (!configuration) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Please configure the document first`),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = data.fields;
|
||||
|
||||
// Use the externalId from the URL fragment if available
|
||||
const templateExternalId = externalId || configuration.meta.externalId;
|
||||
|
||||
const updateResult = await updateEmbeddingTemplate({
|
||||
templateId: template.id,
|
||||
title: configuration.title,
|
||||
externalId: templateExternalId,
|
||||
meta: {
|
||||
...configuration.meta,
|
||||
drawSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW)
|
||||
: undefined,
|
||||
typedSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE)
|
||||
: undefined,
|
||||
uploadSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||
: undefined,
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
name: signer.name,
|
||||
email: signer.email,
|
||||
role: signer.role,
|
||||
signingOrder: signer.signingOrder,
|
||||
fields: fields
|
||||
.filter((field) => field.signerEmail === signer.email)
|
||||
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map<any>((f) => ({
|
||||
...f,
|
||||
id: f.nativeId,
|
||||
pageX: f.pageX,
|
||||
pageY: f.pageY,
|
||||
width: f.pageWidth,
|
||||
height: f.pageHeight,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Template updated successfully`),
|
||||
});
|
||||
|
||||
// Send a message to the parent window with the template details
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'template-updated',
|
||||
templateId: updateResult.templateId,
|
||||
externalId: templateExternalId,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating template:', err);
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to update template`),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
try {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||
JSON.parse(decodeURIComponent(atob(hash))),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeatures(result.data.features);
|
||||
|
||||
// Extract externalId from the parsed data if available
|
||||
if (result.data.externalId) {
|
||||
setExternalId(result.data.externalId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing embedding params:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
|
||||
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
|
||||
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
|
||||
<ConfigureDocumentView
|
||||
defaultValues={configuration ?? undefined}
|
||||
disableUpload={true}
|
||||
onSubmit={handleConfigurePageViewSubmit}
|
||||
/>
|
||||
|
||||
<ConfigureFieldsView
|
||||
configData={configuration!}
|
||||
documentData={template.templateDocumentData}
|
||||
defaultValues={fields ?? undefined}
|
||||
onBack={handleBackToConfig}
|
||||
onSubmit={handleConfigureFieldsSubmit}
|
||||
/>
|
||||
</Stepper>
|
||||
</ConfigureDocumentProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import { TrpcProvider, trpc } from '@documenso/trpc/react';
|
||||
|
||||
import { EmbedClientLoading } from '~/components/embed/embed-client-loading';
|
||||
import { ZBaseEmbedAuthoringSchema } from '~/types/embed-authoring-base-schema';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
export default function AuthoringLayout() {
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
const {
|
||||
mutateAsync: verifyEmbeddingPresignToken,
|
||||
isPending: isVerifyingEmbeddingPresignToken,
|
||||
data: isVerified,
|
||||
} = trpc.embeddingPresign.verifyEmbeddingPresignToken.useMutation();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
try {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||
JSON.parse(decodeURIComponent(atob(hash))),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { token, css, cssVars, darkModeDisabled } = result.data;
|
||||
|
||||
if (darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
injectCss({
|
||||
css,
|
||||
cssVars,
|
||||
});
|
||||
|
||||
void verifyEmbeddingPresignToken({ token }).then((result) => {
|
||||
if (result.success) {
|
||||
setToken(token);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error verifying embedding presign token:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isVerifyingEmbeddingPresignToken) {
|
||||
return <EmbedClientLoading />;
|
||||
}
|
||||
|
||||
if (typeof isVerified !== 'undefined' && !isVerified.success) {
|
||||
return <div>Invalid embedding presign token</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TrpcProvider headers={{ authorization: `Bearer ${token}` }}>
|
||||
<Outlet />
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
@ -100,5 +100,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.10.0-rc.5"
|
||||
"version": "1.10.1"
|
||||
}
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.10.0-rc.5",
|
||||
"version": "1.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.10.0-rc.5",
|
||||
"version": "1.10.1",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -95,7 +95,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.10.0-rc.5",
|
||||
"version": "1.10.1",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.10.0-rc.5",
|
||||
"version": "1.10.1",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@ -45,11 +45,14 @@ test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
page
|
||||
.locator('input[type=file]')
|
||||
.nth(1)
|
||||
.evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
@ -641,7 +644,7 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
|
||||
@ -0,0 +1,842 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
import { FolderType } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('create folder button is visible on documents page', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create Folder' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can create a document folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Folder name').fill('My folder');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByText('My folder')).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
await expect(page.locator('div').filter({ hasText: 'My folder' }).nth(3)).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can create a document subfolder inside a document folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Contracts',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/f/${folder.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Client Contracts')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Folder name').fill('Invoices');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByText('Invoices')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can create a document inside a document folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Contracts',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/f/${folder.id}`,
|
||||
});
|
||||
|
||||
const fileInput = page.locator('input[type="file"]').nth(1);
|
||||
await fileInput.waitFor({ state: 'attached' });
|
||||
|
||||
await fileInput.setInputFiles(
|
||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||
);
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
|
||||
await page.goto(`/documents/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can pin a document folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contracts',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('svg.text-documenso.h-3.w-3')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can unpin a document folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contracts',
|
||||
pinned: true,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('svg.text-documenso.h-3.w-3')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('user can rename a document folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contracts',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('Archive');
|
||||
await page.getByRole('button', { name: 'Save Changes' }).click();
|
||||
|
||||
await expect(page.getByText('Archive')).toBeVisible();
|
||||
});
|
||||
|
||||
test('document folder visibility is not visible to user', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contracts',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('document folder can be moved to another document folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Clients',
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contracts',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).nth(0).click();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Clients' }).click();
|
||||
await page.getByRole('button', { name: 'Move Folder' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto(`/documents/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText('Contracts')).toBeVisible();
|
||||
});
|
||||
|
||||
test('document folder can be moved to the root', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const parentFolder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Clients',
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contracts',
|
||||
parentId: parentFolder.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByText('Clients').click();
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).nth(0).click();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Root' }).click();
|
||||
await page.getByRole('button', { name: 'Move Folder' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto('/documents');
|
||||
|
||||
await expect(page.getByText('Clients')).toBeVisible();
|
||||
});
|
||||
|
||||
test('document folder and its contents can be deleted', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Proposals',
|
||||
},
|
||||
});
|
||||
|
||||
const proposal = await seedBlankDocument(user, {
|
||||
createDocumentOptions: {
|
||||
title: 'Proposal 1',
|
||||
folderId: folder.id,
|
||||
},
|
||||
});
|
||||
|
||||
const reportsFolder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Reports',
|
||||
parentId: folder.id,
|
||||
},
|
||||
});
|
||||
|
||||
const report = await seedBlankDocument(user, {
|
||||
createDocumentOptions: {
|
||||
title: 'Report 1',
|
||||
folderId: reportsFolder.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.goto('/documents');
|
||||
|
||||
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
|
||||
await expect(page.getByText(proposal.title)).not.toBeVisible();
|
||||
|
||||
await page.goto(`/documents/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText(report.title)).not.toBeVisible();
|
||||
await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('user can move a document to a document folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Proposals',
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankDocument(user, {
|
||||
createDocumentOptions: {
|
||||
title: 'Proposal 1',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByTestId('document-table-action-btn').click();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Proposals' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto(`/documents/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText('Proposal 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can move a document from folder to the root', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Proposals',
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankDocument(user, {
|
||||
createDocumentOptions: {
|
||||
title: 'Proposal 1',
|
||||
folderId: folder.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByText('Proposals').click();
|
||||
|
||||
await page.getByTestId('document-table-action-btn').click();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Root' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto('/documents');
|
||||
|
||||
await expect(page.getByText('Proposal 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('create folder button is visible on templates page', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create folder' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can create a template folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Create folder' }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Folder name').fill('My template folder');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByText('My template folder')).toBeVisible();
|
||||
|
||||
await page.goto('/templates');
|
||||
await expect(page.locator('div').filter({ hasText: 'My template folder' }).nth(3)).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can create a template subfolder inside a template folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/f/${folder.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Client Templates')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Create folder' }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Folder name').fill('Contract Templates');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByText('Contract Templates')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can create a template inside a template folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/f/${folder.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Client Templates')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'New Template' }).click();
|
||||
// await expect(page.getByRole('dialog', { name: 'New Template' })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
|
||||
.nth(2)
|
||||
.click();
|
||||
await page.locator('input[type="file"]').waitFor({ state: 'attached' });
|
||||
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
|
||||
await page.goto(`/templates/f/${folder.id}`);
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can pin a template folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contract Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('svg.text-documenso.h-3.w-3')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can unpin a template folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contract Templates',
|
||||
pinned: true,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('svg.text-documenso.h-3.w-3')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('user can rename a template folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contract Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('Updated Template Folder');
|
||||
await page.getByRole('button', { name: 'Save Changes' }).click();
|
||||
|
||||
await expect(page.getByText('Updated Template Folder')).toBeVisible();
|
||||
});
|
||||
|
||||
test('template folder visibility is not visible to user', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contract Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('template folder can be moved to another template folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contract Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).nth(0).click();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Client Templates' }).click();
|
||||
await page.getByRole('button', { name: 'Move Folder' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto(`/templates/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText('Contract Templates')).toBeVisible();
|
||||
});
|
||||
|
||||
test('template folder can be moved to the root', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const parentFolder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contract Templates',
|
||||
parentId: parentFolder.id,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByText('Client Templates').click();
|
||||
await page.getByRole('button', { name: '•••' }).nth(0).click();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Root' }).click();
|
||||
await page.getByRole('button', { name: 'Move Folder' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto('/templates');
|
||||
|
||||
await expect(page.getByText('Contract Templates')).toBeVisible();
|
||||
});
|
||||
|
||||
test('template folder and its contents can be deleted', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Proposal Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
const template = await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Proposal Template 1',
|
||||
folderId: folder.id,
|
||||
},
|
||||
});
|
||||
|
||||
const subfolder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Report Templates',
|
||||
parentId: folder.id,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
const reportTemplate = await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Report Template 1',
|
||||
folderId: subfolder.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: '•••' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.goto('/templates');
|
||||
|
||||
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
|
||||
await expect(page.getByText(template.title)).not.toBeVisible();
|
||||
|
||||
await page.goto(`/templates/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
|
||||
await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('user can navigate between template folders', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const parentFolder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
const subfolder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Contract Templates',
|
||||
parentId: parentFolder.id,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Contract Template 1',
|
||||
folderId: subfolder.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByText('Client Templates').click();
|
||||
await expect(page.getByText('Contract Templates')).toBeVisible();
|
||||
|
||||
await page.getByText('Contract Templates').click();
|
||||
await expect(page.getByText('Contract Template 1')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: parentFolder.name }).click();
|
||||
await expect(page.getByText('Contract Templates')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: subfolder.name }).click();
|
||||
await expect(page.getByText('Contract Template 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can move a template to a template folder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Proposal Template 1',
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByTestId('template-table-action-btn').click();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Client Templates' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await page.goto(`/templates/f/${folder.id}`);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByText('Proposal Template 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('user can move a template from a folder to the root', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const folder = await seedBlankFolder(user, {
|
||||
createFolderOptions: {
|
||||
name: 'Client Templates',
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Proposal Template 1',
|
||||
folderId: folder.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByText('Client Templates').click();
|
||||
|
||||
await page.getByTestId('template-table-action-btn').click();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Root' }).click();
|
||||
await page.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto('/templates');
|
||||
|
||||
await expect(page.getByText('Proposal Template 1')).toBeVisible();
|
||||
});
|
||||
2873
packages/app-tests/e2e/folders/team-account-folders.spec.ts
Normal file
2873
packages/app-tests/e2e/folders/team-account-folders.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -21,4 +21,4 @@
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,22 +3,38 @@ import type { DocumentData } from '@prisma/client';
|
||||
import { getFile } from '../universal/upload/get-file';
|
||||
import { downloadFile } from './download-file';
|
||||
|
||||
type DocumentVersion = 'original' | 'signed';
|
||||
|
||||
type DownloadPDFProps = {
|
||||
documentData: DocumentData;
|
||||
fileName?: string;
|
||||
/**
|
||||
* Specifies which version of the document to download.
|
||||
* 'signed': Downloads the signed version (default).
|
||||
* 'original': Downloads the original version.
|
||||
*/
|
||||
version?: DocumentVersion;
|
||||
};
|
||||
|
||||
export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => {
|
||||
const bytes = await getFile(documentData);
|
||||
export const downloadPDF = async ({
|
||||
documentData,
|
||||
fileName,
|
||||
version = 'signed',
|
||||
}: DownloadPDFProps) => {
|
||||
const bytes = await getFile({
|
||||
type: documentData.type,
|
||||
data: version === 'signed' ? documentData.data : documentData.initialData,
|
||||
});
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||
|
||||
downloadFile({
|
||||
filename: `${baseTitle}_signed.pdf`,
|
||||
filename: `${baseTitle}${suffix}`,
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
@ -17,6 +17,8 @@ export const VALID_DATE_FORMAT_VALUES = [
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
] as const;
|
||||
|
||||
export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
|
||||
|
||||
export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'yyyy-MM-dd_hh:mm_a',
|
||||
@ -94,3 +96,7 @@ export const convertToLocalSystemFormat = (
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
export const isValidDateFormat = (dateFormat: unknown): dateFormat is ValidDateFormat => {
|
||||
return VALID_DATE_FORMAT_VALUES.includes(dateFormat as ValidDateFormat);
|
||||
};
|
||||
|
||||
@ -9,6 +9,7 @@ import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
|
||||
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
|
||||
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||
|
||||
/**
|
||||
@ -27,6 +28,7 @@ export const jobsClient = new JobClient([
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||
EXECUTE_WEBHOOK_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@ -25,6 +25,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
uploadSignatureEnabled: z.boolean(),
|
||||
drawSignatureEnabled: z.boolean(),
|
||||
allowEmbeddedAuthoring: z.boolean(),
|
||||
})
|
||||
.nullish(),
|
||||
}),
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import { Prisma, WebhookCallStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TExecuteWebhookJobDefinition } from './execute-webhook';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TExecuteWebhookJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const { event, webhookId, data } = payload;
|
||||
|
||||
const webhook = await prisma.webhook.findUniqueOrThrow({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
const { webhookUrl: url, secret } = webhook;
|
||||
|
||||
await io.runTask('execute-webhook', async () => {
|
||||
const payloadData = {
|
||||
event,
|
||||
payload: data,
|
||||
createdAt: new Date().toISOString(),
|
||||
webhookEndpoint: url,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payloadData),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Documenso-Secret': secret ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
|
||||
|
||||
try {
|
||||
responseBody = JSON.parse(body);
|
||||
} catch (err) {
|
||||
responseBody = body;
|
||||
}
|
||||
|
||||
await prisma.webhookCall.create({
|
||||
data: {
|
||||
url,
|
||||
event,
|
||||
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
|
||||
requestBody: payloadData as Prisma.InputJsonValue,
|
||||
responseCode: response.status,
|
||||
responseBody,
|
||||
responseHeaders: Object.fromEntries(response.headers.entries()),
|
||||
webhookId: webhook.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook execution failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
};
|
||||
});
|
||||
};
|
||||
34
packages/lib/jobs/definitions/internal/execute-webhook.ts
Normal file
34
packages/lib/jobs/definitions/internal/execute-webhook.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const EXECUTE_WEBHOOK_JOB_DEFINITION_ID = 'internal.execute-webhook';
|
||||
|
||||
const EXECUTE_WEBHOOK_JOB_DEFINITION_SCHEMA = z.object({
|
||||
event: z.nativeEnum(WebhookTriggerEvents),
|
||||
webhookId: z.string(),
|
||||
data: z.unknown(),
|
||||
requestMetadata: ZRequestMetadataSchema.optional(),
|
||||
});
|
||||
|
||||
export type TExecuteWebhookJobDefinition = z.infer<typeof EXECUTE_WEBHOOK_JOB_DEFINITION_SCHEMA>;
|
||||
|
||||
export const EXECUTE_WEBHOOK_JOB_DEFINITION = {
|
||||
id: EXECUTE_WEBHOOK_JOB_DEFINITION_ID,
|
||||
name: 'Execute Webhook',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: EXECUTE_WEBHOOK_JOB_DEFINITION_ID,
|
||||
schema: EXECUTE_WEBHOOK_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./execute-webhook.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof EXECUTE_WEBHOOK_JOB_DEFINITION_ID,
|
||||
TExecuteWebhookJobDefinition
|
||||
>;
|
||||
@ -22,6 +22,7 @@ import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../../types/webhook-payload';
|
||||
import { prefixedId } from '../../../universal/id';
|
||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
|
||||
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
||||
@ -130,6 +131,17 @@ export const run = async ({
|
||||
documentData.data = documentData.initialData;
|
||||
}
|
||||
|
||||
if (!document.qrToken) {
|
||||
await prisma.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
qrToken: prefixedId('qr'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const pdfData = await getFileServerSide(documentData);
|
||||
|
||||
const certificateData =
|
||||
|
||||
@ -1,16 +1,7 @@
|
||||
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
name: string;
|
||||
signingVolume: number;
|
||||
createdAt: Date;
|
||||
planId: string;
|
||||
};
|
||||
|
||||
export type GetSigningVolumeOptions = {
|
||||
type GetSigningVolumeOptions = {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@ -18,83 +9,187 @@ export type GetSigningVolumeOptions = {
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export async function getSigningVolume({
|
||||
export const getSigningVolume = async ({
|
||||
search = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
sortBy = 'signingVolume',
|
||||
sortOrder = 'desc',
|
||||
}: GetSigningVolumeOptions) {
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
}: GetSigningVolumeOptions) => {
|
||||
const validPage = Math.max(1, page);
|
||||
const validPerPage = Math.max(1, perPage);
|
||||
const skip = (validPage - 1) * validPerPage;
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.leftJoin('User as u', 's.userId', 'u.id')
|
||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||
.leftJoin('Document as ud', (join) =>
|
||||
join
|
||||
.onRef('u.id', '=', 'ud.userId')
|
||||
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('ud.deletedAt', 'is', null)
|
||||
.on('ud.teamId', 'is', null),
|
||||
)
|
||||
.leftJoin('Document as td', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'td.teamId')
|
||||
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('td.deletedAt', 'is', null),
|
||||
)
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('u.name', 'ilike', `%${search}%`),
|
||||
eb('u.email', 'ilike', `%${search}%`),
|
||||
eb('t.name', 'ilike', `%${search}%`),
|
||||
]),
|
||||
)
|
||||
.select([
|
||||
's.id as id',
|
||||
's.createdAt as createdAt',
|
||||
's.planId as planId',
|
||||
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
|
||||
const activeSubscriptions = await prisma.subscription.findMany({
|
||||
where: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
planId: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
teamEmail: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
findQuery = findQuery.orderBy('name', sortOrder);
|
||||
break;
|
||||
case 'createdAt':
|
||||
findQuery = findQuery.orderBy('createdAt', sortOrder);
|
||||
break;
|
||||
case 'signingVolume':
|
||||
findQuery = findQuery.orderBy('signingVolume', sortOrder);
|
||||
break;
|
||||
default:
|
||||
findQuery = findQuery.orderBy('signingVolume', 'desc');
|
||||
}
|
||||
const userSubscriptionsMap = new Map();
|
||||
const teamSubscriptionsMap = new Map();
|
||||
|
||||
findQuery = findQuery.limit(perPage).offset(offset);
|
||||
activeSubscriptions.forEach((subscription) => {
|
||||
const isTeam = !!subscription.teamId;
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.leftJoin('User as u', 's.userId', 'u.id')
|
||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('u.name', 'ilike', `%${search}%`),
|
||||
eb('u.email', 'ilike', `%${search}%`),
|
||||
eb('t.name', 'ilike', `%${search}%`),
|
||||
]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
if (isTeam && subscription.teamId) {
|
||||
if (!teamSubscriptionsMap.has(subscription.teamId)) {
|
||||
teamSubscriptionsMap.set(subscription.teamId, {
|
||||
id: subscription.id,
|
||||
planId: subscription.planId,
|
||||
teamId: subscription.teamId,
|
||||
name: subscription.team?.name || '',
|
||||
email: subscription.team?.teamEmail?.email || `Team ${subscription.team?.id}`,
|
||||
createdAt: subscription.team?.createdAt,
|
||||
isTeam: true,
|
||||
subscriptionIds: [subscription.id],
|
||||
});
|
||||
} else {
|
||||
const existingTeam = teamSubscriptionsMap.get(subscription.teamId);
|
||||
existingTeam.subscriptionIds.push(subscription.id);
|
||||
}
|
||||
} else if (subscription.userId) {
|
||||
if (!userSubscriptionsMap.has(subscription.userId)) {
|
||||
userSubscriptionsMap.set(subscription.userId, {
|
||||
id: subscription.id,
|
||||
planId: subscription.planId,
|
||||
userId: subscription.userId,
|
||||
name: subscription.user?.name || '',
|
||||
email: subscription.user?.email || '',
|
||||
createdAt: subscription.user?.createdAt,
|
||||
isTeam: false,
|
||||
subscriptionIds: [subscription.id],
|
||||
});
|
||||
} else {
|
||||
const existingUser = userSubscriptionsMap.get(subscription.userId);
|
||||
existingUser.subscriptionIds.push(subscription.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
const subscriptions = [
|
||||
...Array.from(userSubscriptionsMap.values()),
|
||||
...Array.from(teamSubscriptionsMap.values()),
|
||||
];
|
||||
|
||||
const filteredSubscriptions = search
|
||||
? subscriptions.filter((sub) => {
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
sub.name?.toLowerCase().includes(searchLower) ||
|
||||
sub.email?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
})
|
||||
: subscriptions;
|
||||
|
||||
const signingVolume = await Promise.all(
|
||||
filteredSubscriptions.map(async (subscription) => {
|
||||
let signingVolume = 0;
|
||||
|
||||
if (subscription.userId && !subscription.isTeam) {
|
||||
const personalCount = await prisma.document.count({
|
||||
where: {
|
||||
userId: subscription.userId,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
signingVolume += personalCount;
|
||||
|
||||
const userTeams = await prisma.teamMember.findMany({
|
||||
where: {
|
||||
userId: subscription.userId,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (userTeams.length > 0) {
|
||||
const teamIds = userTeams.map((team) => team.teamId);
|
||||
const teamCount = await prisma.document.count({
|
||||
where: {
|
||||
teamId: {
|
||||
in: teamIds,
|
||||
},
|
||||
status: DocumentStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
|
||||
signingVolume += teamCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (subscription.teamId) {
|
||||
const teamCount = await prisma.document.count({
|
||||
where: {
|
||||
teamId: subscription.teamId,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
|
||||
signingVolume += teamCount;
|
||||
}
|
||||
|
||||
return {
|
||||
...subscription,
|
||||
signingVolume,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const sortedResults = [...signingVolume].sort((a, b) => {
|
||||
if (sortBy === 'name') {
|
||||
return sortOrder === 'asc'
|
||||
? (a.name || '').localeCompare(b.name || '')
|
||||
: (b.name || '').localeCompare(a.name || '');
|
||||
}
|
||||
|
||||
if (sortBy === 'createdAt') {
|
||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
|
||||
}
|
||||
|
||||
return sortOrder === 'asc'
|
||||
? a.signingVolume - b.signingVolume
|
||||
: b.signingVolume - a.signingVolume;
|
||||
});
|
||||
|
||||
const paginatedResults = sortedResults.slice(skip, skip + validPerPage);
|
||||
|
||||
const totalPages = Math.ceil(sortedResults.length / validPerPage);
|
||||
|
||||
return {
|
||||
leaderboard: results,
|
||||
totalPages: Math.ceil(Number(count) / perPage),
|
||||
leaderboard: paginatedResults,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -13,7 +13,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
|
||||
@ -142,6 +142,7 @@ export const createDocumentV2 = async ({
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId: data.externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
@ -232,6 +233,7 @@ export const createDocumentV2 = async ({
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
|
||||
import type { Team, TeamGlobalSettings } from '@prisma/client';
|
||||
import type { DocumentVisibility, Team, TeamGlobalSettings } from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
@ -13,6 +13,7 @@ import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { prefixedId } from '../../universal/id';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
@ -28,6 +29,7 @@ export type CreateDocumentOptions = {
|
||||
normalizePdf?: boolean;
|
||||
timezone?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const createDocument = async ({
|
||||
@ -40,6 +42,7 @@ export const createDocument = async ({
|
||||
formValues,
|
||||
requestMetadata,
|
||||
timezone,
|
||||
folderId,
|
||||
}: CreateDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@ -88,6 +91,29 @@ export const createDocument = async ({
|
||||
userTeamRole = teamWithUserRole.members[0]?.role;
|
||||
}
|
||||
|
||||
let folderVisibility: DocumentVisibility | undefined;
|
||||
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
visibility: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
folderVisibility = folder.visibility;
|
||||
}
|
||||
|
||||
if (normalizePdf) {
|
||||
const documentData = await prisma.documentData.findFirst({
|
||||
where: {
|
||||
@ -115,14 +141,18 @@ export const createDocument = async ({
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
visibility: determineDocumentVisibility(
|
||||
team?.teamGlobalSettings?.documentVisibility,
|
||||
userTeamRole ?? TeamMemberRole.MEMBER,
|
||||
),
|
||||
folderId,
|
||||
visibility:
|
||||
folderVisibility ??
|
||||
determineDocumentVisibility(
|
||||
team?.teamGlobalSettings?.documentVisibility,
|
||||
userTeamRole ?? TeamMemberRole.MEMBER,
|
||||
),
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
|
||||
@ -3,6 +3,7 @@ import { DocumentSource, type Prisma } from '@prisma/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { prefixedId } from '../../universal/id';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export interface DuplicateDocumentOptions {
|
||||
@ -56,6 +57,7 @@ export const duplicateDocument = async ({
|
||||
const createDocumentArguments: Prisma.DocumentCreateArgs = {
|
||||
data: {
|
||||
title: document.title,
|
||||
qrToken: prefixedId('qr'),
|
||||
user: {
|
||||
connect: {
|
||||
id: document.userId,
|
||||
|
||||
@ -27,6 +27,7 @@ export type FindDocumentsOptions = {
|
||||
period?: PeriodSelectorValue;
|
||||
senderIds?: number[];
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const findDocuments = async ({
|
||||
@ -41,6 +42,7 @@ export const findDocuments = async ({
|
||||
period,
|
||||
senderIds,
|
||||
query = '',
|
||||
folderId,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@ -120,10 +122,10 @@ export const findDocuments = async ({
|
||||
},
|
||||
];
|
||||
|
||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
|
||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
|
||||
|
||||
if (team) {
|
||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
|
||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
|
||||
}
|
||||
|
||||
if (filters === null) {
|
||||
@ -227,6 +229,12 @@ export const findDocuments = async ({
|
||||
};
|
||||
}
|
||||
|
||||
if (folderId !== undefined) {
|
||||
whereClause.folderId = folderId;
|
||||
} else {
|
||||
whereClause.folderId = null;
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
where: whereClause,
|
||||
@ -273,13 +281,18 @@ export const findDocuments = async ({
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
|
||||
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
const findDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
user: User,
|
||||
folderId?: string | null,
|
||||
) => {
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
@ -288,6 +301,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@ -296,6 +310,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@ -324,6 +339,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@ -336,6 +352,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@ -345,6 +362,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
@ -353,6 +371,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@ -362,6 +381,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
@ -371,6 +391,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
},
|
||||
},
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@ -410,6 +431,7 @@ const findTeamDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
team: Team & { teamEmail: TeamEmail | null },
|
||||
visibilityFilters: Prisma.DocumentWhereInput[],
|
||||
folderId?: string,
|
||||
) => {
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
|
||||
@ -420,6 +442,7 @@ const findTeamDocumentsFilter = (
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
folderId: folderId,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
@ -437,6 +460,7 @@ const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
});
|
||||
|
||||
// Filter to display all documents that have been sent by the team email.
|
||||
@ -445,6 +469,7 @@ const findTeamDocumentsFilter = (
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
@ -470,6 +495,7 @@ const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
};
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => {
|
||||
@ -479,6 +505,7 @@ const findTeamDocumentsFilter = (
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -490,6 +517,7 @@ const findTeamDocumentsFilter = (
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
@ -502,6 +530,7 @@ const findTeamDocumentsFilter = (
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -521,12 +550,14 @@ const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
folderId: folderId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetDocumentByAccessTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
qrToken: token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
completedAt: true,
|
||||
documentData: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
},
|
||||
},
|
||||
documentMeta: {
|
||||
select: {
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@ -12,9 +12,15 @@ export type GetDocumentByIdOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
|
||||
export const getDocumentById = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
}: GetDocumentByIdOptions) => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
@ -22,7 +28,10 @@ export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumen
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
where: {
|
||||
...documentWhereInput,
|
||||
folderId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
|
||||
@ -7,12 +7,14 @@ export type GetDocumentWithDetailsByIdOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getDocumentWithDetailsById = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
}: GetDocumentWithDetailsByIdOptions) => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
documentId,
|
||||
@ -21,12 +23,16 @@ export const getDocumentWithDetailsById = async ({
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
where: {
|
||||
...documentWhereInput,
|
||||
folderId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -15,9 +15,16 @@ export type GetStatsInput = {
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => {
|
||||
export const getStats = async ({
|
||||
user,
|
||||
period,
|
||||
search = '',
|
||||
folderId,
|
||||
...options
|
||||
}: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
|
||||
if (period) {
|
||||
@ -37,8 +44,9 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
||||
currentUserEmail: user.email,
|
||||
userId: user.id,
|
||||
search,
|
||||
folderId,
|
||||
})
|
||||
: getCounts({ user, createdAt, search }));
|
||||
: getCounts({ user, createdAt, search, folderId }));
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
@ -84,9 +92,10 @@ type GetCountsOption = {
|
||||
user: User;
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
@ -95,6 +104,8 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
return Promise.all([
|
||||
// Owner counts.
|
||||
prisma.document.groupBy({
|
||||
@ -107,7 +118,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
createdAt,
|
||||
teamId: null,
|
||||
deletedAt: null,
|
||||
AND: [searchFilter],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
// Not signed counts.
|
||||
@ -126,7 +137,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
AND: [searchFilter],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
// Has signed counts.
|
||||
@ -164,7 +175,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@ -179,10 +190,11 @@ type GetTeamCountsOption = {
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
currentTeamMemberRole?: TeamMemberRole;
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
const { createdAt, teamId, teamEmail } = options;
|
||||
const { createdAt, teamId, teamEmail, folderId } = options;
|
||||
|
||||
const senderIds = options.senderIds ?? [];
|
||||
|
||||
@ -206,6 +218,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
createdAt,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
folderId,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
@ -278,6 +291,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
where: {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
@ -298,6 +312,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
where: {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
|
||||
86
packages/lib/server-only/folder/create-folder.ts
Normal file
86
packages/lib/server-only/folder/create-folder.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type { Team, TeamGlobalSettings } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { FolderType } from '../../types/folder-type';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
|
||||
export interface CreateFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const createFolder = async ({
|
||||
userId,
|
||||
teamId,
|
||||
name,
|
||||
parentId,
|
||||
type = FolderType.DOCUMENT,
|
||||
}: CreateFolderOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
teamMembers: {
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
teamId !== undefined &&
|
||||
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
|
||||
let userTeamRole: TeamMemberRole | undefined;
|
||||
|
||||
if (teamId) {
|
||||
const teamWithUserRole = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
include: {
|
||||
teamGlobalSettings: true,
|
||||
members: {
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
team = teamWithUserRole;
|
||||
userTeamRole = teamWithUserRole.members[0]?.role;
|
||||
}
|
||||
|
||||
return await prisma.folder.create({
|
||||
data: {
|
||||
name,
|
||||
userId,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
visibility: determineDocumentVisibility(
|
||||
team?.teamGlobalSettings?.documentVisibility,
|
||||
userTeamRole ?? TeamMemberRole.MEMBER,
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
85
packages/lib/server-only/folder/delete-folder.ts
Normal file
85
packages/lib/server-only/folder/delete-folder.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface DeleteFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
}
|
||||
|
||||
export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOptions) => {
|
||||
let teamMemberRole: TeamMemberRole | null = null;
|
||||
|
||||
if (teamId) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
teamMemberRole = team.members[0]?.role ?? null;
|
||||
}
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
include: {
|
||||
documents: true,
|
||||
subfolders: true,
|
||||
templates: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (teamId && teamMemberRole) {
|
||||
const hasPermission = match(teamMemberRole)
|
||||
.with(TeamMemberRole.ADMIN, () => true)
|
||||
.with(TeamMemberRole.MANAGER, () => folder.visibility !== DocumentVisibility.ADMIN)
|
||||
.with(TeamMemberRole.MEMBER, () => folder.visibility === DocumentVisibility.EVERYONE)
|
||||
.otherwise(() => false);
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to delete this folder',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.folder.delete({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
});
|
||||
};
|
||||
152
packages/lib/server-only/folder/find-folders.ts
Normal file
152
packages/lib/server-only/folder/find-folders.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
|
||||
export interface FindFoldersOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
|
||||
let team = null;
|
||||
let teamMemberRole = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
try {
|
||||
team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
teamMemberRole = team.members[0].role;
|
||||
} catch (error) {
|
||||
console.error('Error finding team:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const visibilityFilters = match(teamMemberRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{ parentId },
|
||||
teamId
|
||||
? {
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
}
|
||||
: { userId, teamId: null },
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
...whereClause,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
const foldersWithDetails = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
try {
|
||||
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
where: {
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.template.count({
|
||||
where: {
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
|
||||
...subfolder,
|
||||
subfolders: [],
|
||||
_count: {
|
||||
documents: 0,
|
||||
templates: 0,
|
||||
subfolders: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
...folder,
|
||||
subfolders: subfoldersWithEmptySubfolders,
|
||||
_count: {
|
||||
documents: documentCount,
|
||||
templates: templateCount,
|
||||
subfolders: subfolderCount,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing folder:', folder.id, error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return foldersWithDetails;
|
||||
} catch (error) {
|
||||
console.error('Error in findFolders:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
112
packages/lib/server-only/folder/get-folder-breadcrumbs.ts
Normal file
112
packages/lib/server-only/folder/get-folder-breadcrumbs.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
|
||||
export interface GetFolderBreadcrumbsOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const getFolderBreadcrumbs = async ({
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
type,
|
||||
}: GetFolderBreadcrumbsOptions) => {
|
||||
let teamMemberRole = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
try {
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
teamMemberRole = team.members[0].role;
|
||||
} catch (error) {
|
||||
console.error('Error finding team:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const visibilityFilters = match(teamMemberRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
||||
|
||||
const whereClause = (folderId: string) => ({
|
||||
id: folderId,
|
||||
...(type ? { type } : {}),
|
||||
...(teamId
|
||||
? {
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
}
|
||||
: { userId, teamId: null }),
|
||||
});
|
||||
|
||||
const breadcrumbs = [];
|
||||
let currentFolderId = folderId;
|
||||
|
||||
const currentFolder = await prisma.folder.findFirst({
|
||||
where: whereClause(currentFolderId),
|
||||
});
|
||||
|
||||
if (!currentFolder) {
|
||||
return [];
|
||||
}
|
||||
|
||||
breadcrumbs.push(currentFolder);
|
||||
|
||||
while (currentFolder?.parentId) {
|
||||
const parentFolder = await prisma.folder.findFirst({
|
||||
where: whereClause(currentFolder.parentId),
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
break;
|
||||
}
|
||||
|
||||
breadcrumbs.unshift(parentFolder);
|
||||
currentFolderId = parentFolder.id;
|
||||
currentFolder.parentId = parentFolder.parentId;
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user