Compare commits

..

4 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan c3d3b934c4 fix: build errors 2025-05-01 14:29:14 +00:00
Ephraim Duncan 201dc0f3fa Merge branch 'main' into feat/bin-tab 2025-04-18 23:41:53 +00:00
Ephraim Atta-Duncan c560b9e9e3 feat: add restore deleted document dialog 2025-03-13 22:09:07 +00:00
Ephraim Atta-Duncan 27cd8f9c25 feat: deleted documents bin 2025-03-13 19:45:11 +00:00
257 changed files with 2888 additions and 16958 deletions
-3
View File
@@ -31,9 +31,6 @@ jobs:
- name: Build app
run: npm run build
- name: Install playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run ci
env:
@@ -1,5 +1,3 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
@@ -57,8 +55,6 @@ export function AssistantConfirmationDialog({
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
@@ -111,72 +107,53 @@ export function AssistantConfirmationDialog({
<div className="mt-4 flex flex-col gap-4">
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<div className="my-2">
<p className="text-muted-foreground mb-1 text-sm font-semibold">
The next recipient to sign this document will be{' '}
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
<div className="flex flex-col gap-4 rounded-xl border p-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
)}
@@ -162,7 +162,16 @@ export const DocumentDeleteDialog = ({
</ul>
</AlertDescription>
))
.exhaustive()}
// DocumentStatus.REJECTED isnt working currently so this is a fallback to prevent 500 error.
// The union should work but currently its not
.otherwise(() => (
<AlertDescription>
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
</AlertDescription>
))}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
@@ -1,216 +0,0 @@
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 Recipient, SigningStatus } from '@prisma/client';
import type { Team } from '@prisma/client';
import { type Document, 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,7 +43,9 @@ import { StackAvatar } from '../general/stack-avatar';
const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: TDocumentRow;
document: Document & {
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: Recipient[];
};
@@ -0,0 +1,119 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DocumentRestoreDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onRestore?: () => Promise<void> | void;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export const DocumentRestoreDialog = ({
id,
open,
onOpenChange,
onRestore,
documentTitle,
canManageDocument,
}: DocumentRestoreDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const { mutateAsync: restoreDocument, isPending } =
trpcReact.document.restoreDocument.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: _(msg`Document restored`),
description: _(msg`"${documentTitle}" has been successfully restored`),
duration: 5000,
});
await onRestore?.();
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be restored at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Restore Document</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to restore <strong>"{documentTitle}"</strong>
</Trans>
) : (
<Trans>
You are about to unhide <strong>"{documentTitle}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" className="-mt-1">
<AlertDescription>
{canManageDocument ? (
<Trans>
The document will be restored to your account and will be available in your
documents list.
</Trans>
) : (
<Trans>
The document will be unhidden from your account and will be available in your
documents list.
</Trans>
)}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={() => void restoreDocument({ documentId: id })}
>
<Trans>Restore</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,163 +0,0 @@
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>
);
};
@@ -1,159 +0,0 @@
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>
);
};
@@ -1,169 +0,0 @@
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>
);
};
@@ -1,173 +0,0 @@
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>
);
};
@@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline">
<Button>
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</Button>
@@ -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, folderId }: TemplateCreateDialogProps) => {
export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { user } = useSession();
@@ -53,7 +53,6 @@ export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCre
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId: response.id,
folderId: folderId,
});
toast({
@@ -1,164 +0,0 @@
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>
);
};
@@ -1,163 +0,0 @@
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>
);
};
@@ -1,175 +0,0 @@
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>
);
};
@@ -1,176 +0,0 @@
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>
);
};
@@ -1,213 +0,0 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await moveTemplateToFolder({
templateId,
folderId: data.folderId ?? null,
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team?.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="space-y-2">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Root (No Folder)</Trans>
</Button>
{folders?.data?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -57,7 +57,7 @@ export const ConfigureDocumentRecipients = ({
name: 'signers',
});
const { getValues, watch, setValue } = useFormContext<TConfigureEmbedFormSchema>();
const { getValues, watch } = useFormContext<TConfigureEmbedFormSchema>();
const signingOrder = watch('meta.signingOrder');
@@ -67,16 +67,13 @@ 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:
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1,
});
}, [appendSigner, signers]);
@@ -106,7 +103,7 @@ export const ConfigureDocumentRecipients = ({
// Update signing order for each item
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
...s,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
signingOrder: idx + 1,
}));
// Update the form
@@ -126,7 +123,7 @@ export const ConfigureDocumentRecipients = ({
const currentSigners = getValues('signers');
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
...signer,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
signingOrder: index + 1,
}));
// Update the form with new ordering
@@ -135,16 +132,6 @@ 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">
@@ -165,11 +152,11 @@ export const ConfigureDocumentRecipients = ({
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
onSigningOrderChange(
onCheckedChange={(checked) => {
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
}
);
}}
disabled={isSubmitting}
/>
</FormControl>
@@ -197,7 +184,6 @@ export const ConfigureDocumentRecipients = ({
disabled={isSubmitting || !isSigningOrderEnabled}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
@@ -241,14 +227,13 @@ export const ConfigureDocumentRecipients = ({
key={signer.id}
draggableId={signer.id}
index={index}
isDragDisabled={!isSigningOrderEnabled || isSubmitting || signer.disabled}
isDragDisabled={!isSigningOrderEnabled || isSubmitting}
>
{(provided, snapshot) => (
<fieldset
<div
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,
@@ -364,7 +349,7 @@ export const ConfigureDocumentRecipients = ({
{...field}
isAssistantEnabled={isSigningOrderEnabled}
onValueChange={field.onChange}
disabled={isSubmitting || snapshot.isDragging || signer.disabled}
disabled={isSubmitting || snapshot.isDragging}
/>
</FormControl>
<FormMessage />
@@ -375,18 +360,13 @@ export const ConfigureDocumentRecipients = ({
<Button
type="button"
variant="ghost"
disabled={
isSubmitting ||
signers.length === 1 ||
snapshot.isDragging ||
signer.disabled
}
disabled={isSubmitting || signers.length === 1 || snapshot.isDragging}
onClick={() => removeSigner(index)}
>
<Trash className="h-4 w-4" />
</Button>
</motion.div>
</fieldset>
</div>
)}
</Draggable>
))}
@@ -30,15 +30,10 @@ import {
export interface ConfigureDocumentViewProps {
onSubmit: (data: TConfigureEmbedFormSchema) => void | Promise<void>;
defaultValues?: Partial<TConfigureEmbedFormSchema>;
disableUpload?: boolean;
isSubmitting?: boolean;
}
export const ConfigureDocumentView = ({
onSubmit,
defaultValues,
disableUpload,
}: ConfigureDocumentViewProps) => {
export const ConfigureDocumentView = ({ onSubmit, defaultValues }: ConfigureDocumentViewProps) => {
const { isTemplate } = useConfigureDocument();
const form = useForm<TConfigureEmbedFormSchema>({
@@ -52,7 +47,6 @@ export const ConfigureDocumentView = ({
email: isTemplate ? `recipient.${1}@document.com` : '',
role: RecipientRole.SIGNER,
signingOrder: 1,
disabled: false,
},
],
meta: {
@@ -116,7 +110,7 @@ export const ConfigureDocumentView = ({
/>
</div>
{!disableUpload && <ConfigureDocumentUpload isSubmitting={isSubmitting} />}
<ConfigureDocumentUpload isSubmitting={isSubmitting} />
<ConfigureDocumentRecipients control={control} isSubmitting={isSubmitting} />
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
@@ -15,13 +15,11 @@ 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' }),
@@ -36,7 +34,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).optional(),
allowDictateNextSigner: z.boolean().default(false),
externalId: z.string().optional(),
}),
documentData: z
@@ -2,11 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import type { DocumentData } from '@prisma/client';
import { FieldType, 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';
@@ -15,7 +16,7 @@ import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/fi
import { base64 } from '@documenso/lib/universal/base64';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { useSignerColors } from '@documenso/ui/lib/signer-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
@@ -29,7 +30,6 @@ 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,9 +38,28 @@ 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;
@@ -48,14 +67,13 @@ 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);
@@ -81,11 +99,7 @@ export const ConfigureFieldsView = ({
};
}, []);
const normalizedDocumentData = useMemo(() => {
if (documentData) {
return documentData;
}
const documentData = useMemo(() => {
if (!configData.documentData) {
return null;
}
@@ -102,7 +116,7 @@ export const ConfigureFieldsView = ({
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
id: signer.nativeId || index,
id: index,
name: signer.name || '',
email: signer.email || '',
role: signer.role,
@@ -115,14 +129,14 @@ export const ConfigureFieldsView = ({
signedAt: null,
authOptions: null,
rejectionReason: null,
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.NOT_SENT,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
}));
}, [configData.signers]);
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
() => recipients[0] || null,
);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
@@ -148,7 +162,7 @@ export const ConfigureFieldsView = ({
});
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
const selectedRecipientStyles = useRecipientColors(
const selectedSignerStyles = useSignerColors(
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
);
@@ -173,59 +187,34 @@ export const ConfigureFieldsView = ({
});
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (duplicate) {
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
if (!duplicate) {
setFieldClipboard(lastActiveField);
append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageNumber,
};
append(newField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
return;
}
setFieldClipboard(lastActiveField);
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
id: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
append(newField);
}
},
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
@@ -240,8 +229,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,
@@ -314,6 +303,7 @@ export const ConfigureFieldsView = ({
pageY -= fieldPageHeight / 2;
const field = {
id: nanoid(12),
formId: nanoid(12),
type: selectedField,
pageNumber,
@@ -515,7 +505,7 @@ export const ConfigureFieldsView = ({
<div
className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]',
selectedRecipientStyles.base,
selectedSignerStyles.default.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
@@ -536,9 +526,9 @@ export const ConfigureFieldsView = ({
)}
<Form {...form}>
{normalizedDocumentData && (
{documentData && (
<div>
<PDFViewer documentData={normalizedDocumentData} />
<PDFViewer documentData={documentData} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field, index) => {
@@ -558,7 +548,6 @@ export const ConfigureFieldsView = ({
onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => {
@@ -1,29 +0,0 @@
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,5 +1,6 @@
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';
@@ -7,13 +8,35 @@ 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: TConfigureFieldsFormSchemaField | null;
fields: TConfigureFieldsFormSchemaField[];
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;
}>;
onFieldUpdate: (formId: string, fieldMeta: FieldMeta) => void;
};
@@ -20,7 +20,6 @@ import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@@ -38,6 +37,7 @@ import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-sc
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
import { DocumentReadOnlyFields } from '../general/document/document-read-only-fields';
import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields';
@@ -44,7 +44,6 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditLog: z.boolean(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
@@ -78,7 +77,6 @@ export const TeamDocumentPreferencesForm = ({
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
includeAuditLog: settings?.includeAuditLog ?? false,
signatureTypes: extractTeamSignatureSettings(settings),
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
@@ -93,7 +91,6 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
} = data;
@@ -104,7 +101,6 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
@@ -311,7 +307,7 @@ export const TeamDocumentPreferencesForm = ({
<FormDescription>
<Trans>
Controls whether the signing certificate will be included with the document when
Controls whether the signing certificate will be included in the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
@@ -320,36 +316,6 @@ export const TeamDocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Log in the Document</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the audit log will be included with the document when it is
downloaded. The audit log can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -9,10 +9,6 @@ import { z } from 'zod';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTemplate } from '@documenso/lib/types/template';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
@@ -20,6 +16,7 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import {
Form,
@@ -100,16 +97,14 @@ export const DirectTemplateConfigureForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
{isDocumentPdfLoaded && (
<DocumentReadOnlyFields
fields={mapFieldsWithRecipients(
directTemplateRecipient.fields,
recipientsWithBlankDirectRecipientEmail,
)}
recipientIds={recipients.map((recipient) => recipient.id)}
showRecipientColors={true}
/>
)}
{isDocumentPdfLoaded &&
directTemplateRecipient.fields.map((field, index) => (
<ShowFieldItem
key={index}
field={field}
recipients={recipientsWithBlankDirectRecipientEmail}
/>
))}
<Form {...form}>
<fieldset
@@ -8,7 +8,6 @@ import { useNavigate, useSearchParams } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
@@ -104,17 +103,11 @@ export const DirectTemplatePageView = ({
directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt,
signedFieldValues: fields.map((field) => {
if (isRequiredField(field) && !field.signedValue) {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
return {
token: field.signedValue?.token ?? '',
fieldId: field.signedValue?.fieldId ?? 0,
value: field.signedValue?.value,
isBase64: field.signedValue?.isBase64,
authOptions: field.signedValue?.authOptions,
};
return field.signedValue;
}),
});
@@ -17,7 +17,6 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -79,10 +78,6 @@ export const DirectTemplateSigningForm = ({
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const fieldsRequiringValidation = useMemo(() => {
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
}, [localFields]);
const { currentStep, totalSteps, previousStep } = useStep();
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
@@ -139,18 +134,18 @@ export const DirectTemplateSigningForm = ({
};
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation);
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
}, [localFields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fieldsRequiringValidation);
validateFieldsInserted(localFields);
};
const handleSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
const isFieldsValid = validateFieldsInserted(localFields);
if (!isFieldsValid) {
return;
@@ -97,12 +97,6 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
// Do nothing, this should only happen when the user clicks the field, but
// misses the checkbox which triggers this callback.
if (checkedValues.length === 0) {
return;
}
if (!isLengthConditionMet) {
return;
}
@@ -276,26 +270,21 @@ export const DocumentSigningCheckboxField = ({
{validationSign?.label} {checkboxValidationLength}
</FieldToolTip>
)}
<div className="z-50 my-0.5 flex flex-col gap-y-1">
<div className="z-50 flex flex-col gap-y-2">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center">
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-3 w-3"
id={`checkbox-${field.id}-${item.id}`}
className="h-4 w-4"
id={`checkbox-${index}`}
checked={checkedValues.includes(itemValue)}
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
<Label htmlFor={`checkbox-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
);
})}
@@ -304,27 +293,22 @@ export const DocumentSigningCheckboxField = ({
)}
{field.inserted && (
<div className="my-0.5 flex flex-col gap-y-1">
<div className="flex flex-col gap-y-1">
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`;
return (
<div key={index} className="flex items-center">
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-3 w-3"
id={`checkbox-${field.id}-${item.id}`}
id={`checkbox-${index}`}
checked={parsedCheckedValues.includes(itemValue)}
disabled={isLoading}
onCheckedChange={() => void handleCheckboxOptionClick(item)}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`checkbox-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
<Label htmlFor={`checkbox-${index}`} className="text-xs">
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
);
})}
@@ -281,7 +281,7 @@ export const DocumentSigningCompleteDialog = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
@@ -142,7 +142,7 @@ export const DocumentSigningDateField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Date</Trans>
</p>
)}
@@ -151,10 +151,12 @@ export const DocumentSigningDateField = ({
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-foreground w-full whitespace-nowrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'!text-center': parsedFieldMeta?.textAlign === 'center',
'!text-right': parsedFieldMeta?.textAlign === 'right',
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
@@ -177,11 +177,15 @@ export const DocumentSigningDropdownField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-foreground flex flex-col items-center justify-center duration-200">
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
<Select value={localChoice} onValueChange={handleSelectItem}>
<SelectTrigger
className={cn(
'text-foreground z-10 h-full w-full border-none ring-0 focus:border-none focus:ring-0',
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0',
{
'hover:text-red-300': parsedFieldMeta.required,
'hover:text-yellow-300': !parsedFieldMeta.required,
},
)}
>
<SelectValue
@@ -201,7 +205,7 @@ export const DocumentSigningDropdownField = ({
)}
{field.inserted && (
<p className="text-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}
@@ -1,6 +1,7 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -13,14 +14,10 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -123,18 +120,34 @@ export const DocumentSigningEmailField = ({
return (
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Email</Trans>
</DocumentSigningFieldsUninserted>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
</DocumentSigningFieldContainer>
);
@@ -2,14 +2,12 @@ import React from 'react';
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { X } from 'lucide-react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
@@ -130,51 +128,57 @@ export const DocumentSigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]')}>
<FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
<div className={cn('[container-type:size]', { group: type === 'Checkbox' })}>
<FieldRootContainer field={field}>
{!field.inserted && !loading && !readOnlyField && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
className="absolute inset-0 z-10 h-full w-full rounded-md border"
onClick={async () => handleInsertField()}
/>
)}
{readOnlyField && (
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
<span className="bg-foreground/50 text-background rounded-xl p-2">
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2">
<Trans>Read only field</Trans>
</span>
</button>
)}
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
<Trans>Remove</Trans>
</button>
</TooltipTrigger>
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
<Trans>Remove</Trans>
</button>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
@@ -1,51 +0,0 @@
import { Loader } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
export const DocumentSigningFieldsLoader = () => {
return (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
);
};
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
return (
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{children}
</p>
);
};
type DocumentSigningFieldsInsertedProps = {
children: React.ReactNode;
/**
* The text alignment of the field.
*
* Defaults to left.
*/
textAlign?: 'left' | 'center' | 'right';
};
export const DocumentSigningFieldsInserted = ({
children,
textAlign = 'left',
}: DocumentSigningFieldsInsertedProps) => {
return (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'!text-center': textAlign === 'center',
'!text-right': textAlign === 'right',
},
)}
>
{children}
</p>
</div>
);
};
@@ -1,12 +1,12 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZInitialsFieldMeta } from '@documenso/lib/types/field-meta';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
@@ -17,11 +17,6 @@ import type {
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -55,9 +50,6 @@ export const DocumentSigningInitialsField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const safeFieldMeta = ZInitialsFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
const value = initials ?? '';
@@ -130,18 +122,22 @@ export const DocumentSigningInitialsField = ({
onRemove={onRemove}
type="Initials"
>
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Initials</Trans>
</DocumentSigningFieldsUninserted>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</DocumentSigningFieldsInserted>
</p>
)}
</DocumentSigningFieldContainer>
);
@@ -3,6 +3,7 @@ import { 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 { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -15,6 +16,7 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@@ -23,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
@@ -169,18 +166,34 @@ export const DocumentSigningNameField = ({
onRemove={onRemove}
type="Name"
>
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<Trans>Name</Trans>
</DocumentSigningFieldsUninserted>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
@@ -189,7 +202,7 @@ export const DocumentSigningNameField = ({
<Trans>
Sign as
<div>
{recipient.name} <div className="text-foreground">({recipient.email})</div>
{recipient.name} <div className="text-muted-foreground">({recipient.email})</div>
</div>
</Trans>
</DialogTitle>
@@ -211,7 +224,7 @@ export const DocumentSigningNameField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowFullNameModal(false);
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Hash, Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@@ -24,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type ValidationErrors = {
@@ -249,16 +245,45 @@ export const DocumentSigningNumberField = ({
onRemove={onRemove}
type="Number"
>
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>{fieldDisplayName}</DocumentSigningFieldsUninserted>
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
</span>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText}
</DocumentSigningFieldsInserted>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
@@ -314,7 +339,7 @@ export const DocumentSigningNumberField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowNumberModal(false);
@@ -19,7 +19,6 @@ import {
import type { CompletedField } from '@documenso/lib/types/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@@ -37,6 +36,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
@@ -157,11 +157,7 @@ export const DocumentSigningPageView = ({
</div>
</div>
<DocumentReadOnlyFields
documentMeta={documentMeta || undefined}
fields={completedFields}
showRecipientTooltip={true}
/>
<DocumentReadOnlyFields documentMeta={documentMeta || undefined} fields={completedFields} />
{recipient.role !== RecipientRole.ASSISTANT && (
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -20,7 +21,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { DocumentSigningFieldsLoader } from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningRadioFieldProps = {
@@ -150,52 +150,44 @@ export const DocumentSigningRadioField = ({
return (
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<RadioGroup
onValueChange={(value) => handleSelectItem(value)}
className="z-10 my-0.5 gap-y-1"
>
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="h-3 w-3 shrink-0"
className="h-4 w-4 shrink-0"
value={item.value}
id={`option-${field.id}-${item.id}`}
id={`option-${index}`}
checked={item.checked}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
<Label htmlFor={`option-${index}`}>
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>
)}
{field.inserted && (
<RadioGroup className="my-0.5 gap-y-1">
<RadioGroup className="gap-y-1">
{values?.map((item, index) => (
<div key={index} className="flex items-center">
<div key={index} className="flex items-center gap-x-1.5">
<RadioGroupItem
className="h-3 w-3"
value={item.value}
id={`option-${field.id}-${item.id}`}
id={`option-${index}`}
checked={item.value === field.customText}
/>
{!item.value.includes('empty-value-') && item.value && (
<Label
htmlFor={`option-${field.id}-${item.id}`}
className="text-foreground ml-1.5 text-xs font-normal"
>
{item.value}
</Label>
)}
<Label htmlFor={`option-${index}`} className="text-xs">
{item.value.includes('empty-value-') ? '' : item.value}
</Label>
</div>
))}
</RadioGroup>
@@ -38,11 +38,6 @@ export const DocumentSigningRecipientProvider = ({
recipient,
targetSigner = null,
}: DocumentSigningRecipientProviderProps) => {
// console.log({
// recipient,
// targetSigner,
// isAssistantMode: !!targetSigner,
// });
return (
<DocumentSigningRecipientContext.Provider
value={{
@@ -242,7 +242,7 @@ export const DocumentSigningSignatureField = ({
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
<Trans>Signature</Trans>
</p>
)}
@@ -259,7 +259,7 @@ export const DocumentSigningSignatureField = ({
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p
ref={signatureRef}
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
style={{ fontSize: `${fontSize}rem` }}
>
{signature?.typedSignature}
@@ -291,7 +291,7 @@ export const DocumentSigningSignatureField = ({
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="flex-1"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowSignatureModal(false);
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Loader, Type } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
@@ -24,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningTextFieldProps = {
@@ -252,20 +248,49 @@ export const DocumentSigningTextField = ({
onRemove={onRemove}
type="Text"
>
{isLoading && <DocumentSigningFieldsLoader />}
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<DocumentSigningFieldsUninserted>
{fieldDisplayName || <Trans>Text</Trans>}
</DocumentSigningFieldsUninserted>
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{fieldDisplayName || <Trans>Text</Trans>}
</span>
</span>
</p>
)}
{field.inserted && (
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</DocumentSigningFieldsInserted>
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</p>
</div>
)}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
@@ -279,9 +304,11 @@ export const DocumentSigningTextField = ({
id="custom-text"
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
'text-center': parsedFieldMeta?.textAlign === 'center',
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
})}
value={localText}
@@ -327,8 +354,8 @@ export const DocumentSigningTextField = ({
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
className="flex-1"
onClick={() => {
setShowCustomTextModal(false);
setLocalCustomText('');
@@ -1,106 +0,0 @@
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>
);
};
@@ -1,192 +0,0 @@
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>
);
};
@@ -170,6 +170,7 @@ export const DocumentHistorySheet = ({
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED },
() => null,
)
.with(
@@ -12,14 +12,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
SplitButton,
SplitButtonAction,
SplitButtonDropdown,
SplitButtonDropdownItem,
} from '@documenso/ui/primitives/split-button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewButtonProps = {
@@ -45,15 +38,6 @@ 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 { mutateAsync: downloadCertificate, isPending: isDownloadingCertificate } =
trpc.document.downloadCertificate.useMutation();
const { mutateAsync: downloadAuditLogs, isPending: isDownloadingAuditLogs } =
trpc.document.downloadAuditLogs.useMutation();
const onDownloadClick = async () => {
try {
@@ -84,125 +68,6 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
}
};
const onDownloadOriginalClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
{
documentId: document.id,
},
{
context: {
teamId: document.team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData;
if (!documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData, fileName: documentWithData.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const onDownloadCertificateClick = async () => {
try {
const { url } = await downloadCertificate({ documentId: document.id });
const iframe = Object.assign(window.document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
window.document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
window.document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the certificate. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onDownloadAuditLogClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId: document.id });
const iframe = Object.assign(window.document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
window.document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
window.document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
),
variant: 'destructive',
});
}
};
return match({
isRecipient,
isPending,
@@ -236,32 +101,16 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
))
.with({ isComplete: false }, () => (
<Button className="w-full" asChild>
<Link to={formatPath}>
<Link to={`${documentsPath}/${document.id}/edit`}>
<Trans>Edit</Trans>
</Link>
</Button>
))
.with({ isComplete: true }, () => (
<SplitButton className="w-full">
<SplitButtonAction onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</SplitButtonAction>
<SplitButtonDropdown>
<SplitButtonDropdownItem onClick={onDownloadOriginalClick}>
<Trans>Download Original Document</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={onDownloadCertificateClick}>
<Trans>Download Document Certificate</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={onDownloadAuditLogClick}>
<Trans>Download Audit Log</Trans>
</SplitButtonDropdownItem>
</SplitButtonDropdown>
</SplitButton>
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
))
.otherwise(() => null);
};
@@ -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 type { Document, Recipient, Team, User } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import type { Document, Recipient, Team, User } from '@prisma/client';
import {
Copy,
Download,
@@ -15,7 +15,8 @@ import {
Share,
Trash2,
} from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import { Link } from 'react-router';
import { useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -98,35 +99,6 @@ 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 (
@@ -156,11 +128,6 @@ 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" />
@@ -25,7 +25,7 @@ export const DocumentPageViewInformation = ({
const { _, i18n } = useLingui();
const documentInformation = useMemo(() => {
return [
const documentInfo = [
{
description: msg`Uploaded by`,
value:
@@ -44,6 +44,19 @@ export const DocumentPageViewInformation = ({
.toRelative(),
},
];
if (document.deletedAt) {
documentInfo.push({
description: msg`Deleted`,
value:
document.deletedAt &&
DateTime.fromJSDate(document.deletedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toFormat('MMMM d, yyyy'),
});
}
return documentInfo;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, userId]);
@@ -0,0 +1,171 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import { FieldType, SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: DocumentMeta | TemplateMeta;
showFieldStatus?: boolean;
};
export const DocumentReadOnlyFields = ({
documentMeta,
fields,
showFieldStatus = true,
}: DocumentReadOnlyFieldsProps) => {
const { _ } = useLingui();
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.recipient.name || field.recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'relative flex w-fit flex-col p-4 text-sm',
}}
>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<p className="text-center font-semibold">
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{field.recipient.name
? `${field.recipient.name} (${field.recipient.email})`
: field.recipient.email}{' '}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
</PopoverHover>
</div>
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
{field.recipient.signingStatus === SigningStatus.SIGNED &&
match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.signature?.signatureImageAsBase64 ? (
<img
src={field.signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl">
{field.signature?.typedSignature}
</p>
),
)
.with(
{
type: P.union(
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
),
},
() => field.customText,
)
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
{field.recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<p
className={cn('text-muted-foreground text-lg duration-200', {
'font-signature sm:text-xl md:text-2xl':
field.type === FieldType.SIGNATURE ||
field.type === FieldType.FREE_SIGNATURE,
})}
>
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])}
</p>
)}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};
@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
import { CheckCircle2, Clock, File, Trash, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -36,11 +36,11 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
icon: File,
color: 'text-yellow-500 dark:text-yellow-200',
},
REJECTED: {
label: msg`Rejected`,
labelExtended: msg`Document rejected`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
DELETED: {
label: msg`Deleted`,
labelExtended: msg`Document deleted`,
icon: Trash,
color: 'text-red-700 dark:text-red-500',
},
INBOX: {
label: msg`Inbox`,
@@ -53,6 +53,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
labelExtended: msg`Document All`,
color: 'text-muted-foreground',
},
REJECTED: {
label: msg`Rejected`,
labelExtended: msg`Document rejected`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
};
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
@@ -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, useParams } from 'react-router';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@@ -17,13 +17,7 @@ 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-upload';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
@@ -36,7 +30,6 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { folderId } = useParams();
const team = useOptionalCurrentTeam();
@@ -76,7 +69,6 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
title: file.name,
documentDataId: response.id,
timezone: userTimezone,
folderId: folderId ?? undefined,
});
void refreshLimits();
@@ -93,11 +85,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
timestamp: new Date().toISOString(),
});
await navigate(
folderId
? `${formatDocumentsPath(team?.url)}/f/${folderId}/${id}/edit`
: `${formatDocumentsPath(team?.url)}/${id}/edit`,
);
await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);
@@ -133,31 +121,25 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
return (
<div className={cn('relative', className)}>
<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>
<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>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
@@ -1,88 +0,0 @@
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>
);
};
@@ -1,112 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertCircle } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LegacyFieldWarningPopoverProps = {
type?: 'document' | 'template';
documentId?: number;
templateId?: number;
};
export const LegacyFieldWarningPopover = ({
type = 'document',
documentId,
templateId,
}: LegacyFieldWarningPopoverProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const revalidator = useRevalidator();
const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } =
trpc.template.updateTemplate.useMutation();
const { mutateAsync: updateDocument, isPending: isUpdatingDocument } =
trpc.document.updateDocument.useMutation();
const onUpdateFieldsClick = async () => {
if (type === 'document') {
if (!documentId) {
return;
}
await updateDocument({
documentId,
data: {
useLegacyFieldInsertion: false,
},
});
}
if (type === 'template') {
if (!templateId) {
return;
}
await updateTemplate({
templateId,
data: {
useLegacyFieldInsertion: false,
},
});
}
void revalidator.revalidate();
toast({
title: _(msg`Fields updated`),
description: _(
msg`The fields have been updated to the new field insertion method successfully`,
),
});
};
return (
<PopoverHover
side="bottom"
trigger={
<Button variant="outline" className="h-9 w-9 p-0">
<span className="sr-only">
{type === 'document' ? (
<Trans>Document is using legacy field insertion</Trans>
) : (
<Trans>Template is using legacy field insertion</Trans>
)}
</span>
<AlertCircle className="h-5 w-5" />
</Button>
}
>
<p className="text-muted-foreground text-sm">
{type === 'document' ? (
<Trans>
This document is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
</Trans>
) : (
<Trans>
This template is using legacy field insertion, we recommend using the new field
insertion method for more accurate results.
</Trans>
)}
</p>
<div className="mt-2 flex w-full items-center justify-end">
<Button
type="button"
size="sm"
loading={isUpdatingDocument || isUpdatingTemplate}
onClick={onUpdateFieldsClick}
>
<Trans>Update Fields</Trans>
</Button>
</div>
</PopoverHover>
);
};
@@ -1,53 +0,0 @@
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,11 +208,7 @@ export const TemplateEditForm = ({
duration: 5000,
});
const templatePath = template.folderId
? `${templateRootPath}/f/${template.folderId}`
: templateRootPath;
await navigate(templatePath);
await navigate(templateRootPath);
} catch (err) {
console.error(err);
@@ -14,13 +14,9 @@ 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,6 +1,7 @@
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';
@@ -8,7 +9,6 @@ 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,7 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentsTableActionButtonProps = {
row: TDocumentRow;
row: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
};
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
@@ -40,9 +44,6 @@ 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 {
@@ -95,7 +96,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
<Button className="w-32" asChild>
<Link to={formatPath}>
<Link to={`${documentsPath}/${row.id}/edit`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@@ -3,6 +3,7 @@ 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,
@@ -10,12 +11,11 @@ import {
Download,
Edit,
EyeIcon,
FileDown,
FolderInput,
Loader,
MoreHorizontal,
MoveRight,
Pencil,
RotateCcw,
Share,
Trash2,
} from 'lucide-react';
@@ -23,7 +23,6 @@ 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';
@@ -41,18 +40,19 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRestoreDialog } from '~/components/dialogs/document-restore-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentsTableActionDropdownProps = {
row: TDocumentRow;
onMoveDocument?: () => void;
row: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
};
export const DocumentsTableActionDropdown = ({
row,
onMoveDocument,
}: DocumentsTableActionDropdownProps) => {
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
const { user } = useSession();
const team = useOptionalCurrentTeam();
@@ -60,24 +60,23 @@ export const DocumentsTableActionDropdown = ({
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isOwner = row.user.id === user.id;
// const isRecipient = !!recipient;
const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const isDocumentDeleted = row.deletedAt !== null;
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 {
@@ -105,32 +104,6 @@ export const DocumentsTableActionDropdown = ({
}
};
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 (
@@ -172,7 +145,7 @@ export const DocumentsTableActionDropdown = ({
)}
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
<Link to={formatPath}>
<Link to={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@@ -183,11 +156,6 @@ export const DocumentsTableActionDropdown = ({
<Trans>Download</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDownloadOriginalClick}>
<FileDown 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>
@@ -201,23 +169,23 @@ export const DocumentsTableActionDropdown = ({
</DropdownMenuItem>
)}
{onMoveDocument && (
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
<FolderInput 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" />
Void
</DropdownMenuItem> */}
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
{isDocumentDeleted || (isRecipient && !canManageDocument) ? (
<DropdownMenuItem disabled={isRecipient} onClick={() => setRestoreDialogOpen(true)}>
<RotateCcw className="mr-2 h-4 w-4" />
<Trans>Restore</Trans>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
)}
<DropdownMenuLabel>
<Trans>Share</Trans>
@@ -263,6 +231,15 @@ export const DocumentsTableActionDropdown = ({
canManageDocument={canManageDocument}
/>
<DocumentRestoreDialog
id={row.id}
open={isRestoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
documentTitle={row.title}
teamId={team?.id}
canManageDocument={canManageDocument}
/>
<DocumentMoveDialog
documentId={row.id}
open={isMoveDialogOpen}
@@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { Bird, CheckCircle2, Trash } from 'lucide-react';
import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -30,6 +30,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
icon: Bird,
}))
.with(ExtendedDocumentStatus.DELETED, () => ({
title: msg`Nothing in the trash`,
message: msg`There are no documents in the trash.`,
icon: Trash,
}))
.otherwise(() => ({
title: msg`Nothing to do`,
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
@@ -1,12 +1,16 @@
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: TDocumentRow;
row: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'url'> | null;
recipients: Recipient[];
};
teamUrl?: string;
};
@@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@@ -29,17 +28,11 @@ export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
export const DocumentsTable = ({
data,
isLoading,
isLoadingError,
onMoveDocument,
}: DocumentsTableProps) => {
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
const { _, i18n } = useLingui();
const team = useOptionalCurrentTeam();
@@ -82,19 +75,15 @@ export const DocumentsTable = ({
},
{
header: _(msg`Actions`),
cell: ({ row }) =>
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown
row={row.original}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [team, onMoveDocument]);
}, [team]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
@@ -180,9 +169,6 @@ 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,
@@ -191,7 +177,7 @@ const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
})
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
<Link
to={formatPath}
to={`${documentsPath}/${row.id}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
@@ -2,16 +2,7 @@ import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
import {
Copy,
Edit,
FolderIcon,
MoreHorizontal,
MoveRight,
Share2Icon,
Trash2,
Upload,
} from 'lucide-react';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -28,7 +19,6 @@ 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 & {
@@ -60,18 +50,13 @@ 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 data-testid="template-table-action-btn">
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
@@ -79,7 +64,7 @@ export const TemplatesTableActionDropdown = ({
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link to={formatPath}>
<Link to={`${templateRootPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@@ -98,11 +83,6 @@ 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" />
@@ -155,14 +135,6 @@ export const TemplatesTableActionDropdown = ({
onOpenChange={setDeleteDialogOpen}
onDelete={onDelete}
/>
<TemplateMoveToFolderDialog
templateId={row.id}
templateTitle={row.title}
isOpen={isMoveToFolderDialogOpen}
onOpenChange={setMoveToFolderDialogOpen}
currentFolderId={row.folderId}
/>
</DropdownMenu>
);
};
@@ -54,11 +54,6 @@ 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-muted-foreground text-xs">
<AvatarFallback className="text-xs text-gray-400">
{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-muted-foreground text-xs">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
@@ -2,10 +2,7 @@ import { Trans } from '@lingui/react/macro';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
import {
AdminLeaderboardTable,
type SigningVolume,
} from '~/components/tables/admin-leaderboard-table';
import { AdminLeaderboardTable } from '~/components/tables/admin-leaderboard-table';
import type { Route } from './+types/leaderboard';
@@ -28,7 +25,7 @@ export async function loader({ request }: Route.LoaderArgs) {
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const { leaderboard, totalPages } = await getSigningVolume({
const { leaderboard: signingVolume, totalPages } = await getSigningVolume({
search,
page,
perPage,
@@ -36,14 +33,8 @@ 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: typedSigningVolume,
signingVolume,
totalPages,
page,
perPage,
@@ -57,11 +48,9 @@ export default function Leaderboard({ loaderData }: Route.ComponentProps) {
return (
<div>
<div className="flex items-center">
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
</div>
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
<div className="mt-8">
<AdminLeaderboardTable
signingVolume={signingVolume}
@@ -13,7 +13,6 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
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';
@@ -25,6 +24,7 @@ import { DocumentPageViewDropdown } from '~/components/general/document/document
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 { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import {
DocumentStatus as DocumentStatusComponent,
@@ -64,10 +64,6 @@ 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);
@@ -204,14 +200,8 @@ export default function DocumentPage() {
</CardContent>
</Card>
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
fields={fields}
documentMeta={documentMeta || undefined}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
/>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
@@ -14,7 +14,6 @@ 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 { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
@@ -49,10 +48,6 @@ 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);
@@ -87,10 +82,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
});
return superLoaderJson({
document: {
...document,
folder: null,
},
document,
documentRootPath,
isDocumentEnterprise,
});
@@ -108,43 +100,29 @@ export default function DocumentEditPage() {
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full items-end justify-between">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<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"
/>
<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" />
{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>
</div>
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
@@ -43,26 +43,24 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
const document = await getDocumentById({
documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
// 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,
}),
]);
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,12 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { Link, useSearchParams } 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';
@@ -16,21 +13,12 @@ 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';
@@ -54,27 +42,15 @@ 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,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.DELETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
@@ -90,18 +66,9 @@ export default function DocumentsPage() {
},
);
const {
data: foldersData,
isLoading: isFoldersLoading,
refetch: refetchFolders,
} = trpc.folder.getFolders.useQuery({
type: FolderType.DOCUMENT,
parentId: null,
});
// Refetch the documents when the team URL changes.
useEffect(() => {
void refetch();
void refetchFolders();
}, [team?.url]);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
@@ -126,265 +93,80 @@ 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 (
<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="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<DocumentUploadDropzone />
{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)}
<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 || ExtendedDocumentStatus.ALL}
className="overflow-x-auto"
>
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.DELETED,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<FolderIcon className="h-4 w-4" />
<span>{folder.name}</span>
</Button>
</div>
))}
</div>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
<div className="flex gap-4 sm:flex-row sm:justify-end">
<DocumentUploadDropzone />
<CreateFolderDialog />
{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>
{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>
</DocumentDropZoneWrapper>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
/>
) : (
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
)}
</div>
</div>
</div>
);
}
@@ -1,272 +0,0 @@
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>
);
}
@@ -1,155 +0,0 @@
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>
);
}
@@ -1,199 +0,0 @@
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>
);
}
@@ -1,374 +0,0 @@
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>
);
}
@@ -1,181 +0,0 @@
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>
);
}
@@ -1,5 +0,0 @@
import DocumentPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id._index';
export { loader };
export default DocumentPage;
@@ -1,5 +0,0 @@
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id.edit';
export { loader };
export default DocumentEditPage;
@@ -1,5 +0,0 @@
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents.f.$folderId.$id.logs';
export { loader };
export default DocumentLogsPage;
@@ -1,5 +0,0 @@
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents.f.$folderId._index';
export { meta };
export default DocumentsPage;
@@ -1,5 +0,0 @@
import DocumentsFoldersPage, { meta } from '~/routes/_authenticated+/documents.folders._index';
export { meta };
export default DocumentsFoldersPage;
@@ -1,5 +0,0 @@
import TemplatePage, { loader } from '~/routes/_authenticated+/templates.f.$folderId.$id._index';
export { loader };
export default TemplatePage;
@@ -1,5 +0,0 @@
import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates.f.$folderId.$id.edit';
export { loader };
export default TemplateEditPage;
@@ -1,5 +0,0 @@
import TemplatesPage, { meta } from '~/routes/_authenticated+/templates.f.$folderId._index';
export { meta };
export default TemplatesPage;
@@ -1,5 +0,0 @@
import TemplatesFoldersPage, { meta } from '~/routes/_authenticated+/templates.folders._index';
export { meta };
export default TemplatesFoldersPage;
@@ -7,7 +7,6 @@ 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';
@@ -15,6 +14,7 @@ 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 { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
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';
@@ -55,10 +55,6 @@ 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,
@@ -155,9 +151,6 @@ export default function TemplatePage() {
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta}
/>
@@ -8,7 +8,6 @@ import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
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';
@@ -45,20 +44,13 @@ 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,
folder: null,
},
template,
isTemplateEnterprise,
templateRootPath,
});
@@ -72,11 +64,7 @@ export default function TemplateEditPage() {
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link
to={
template.folderId
? `${templateRootPath}/f/${template.folderId}/${template.id}`
: `${templateRootPath}/${template.id}`
}
to={`${templateRootPath}/${template.id}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
@@ -103,14 +91,8 @@ export default function TemplateEditPage() {
</div>
</div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<div className="mt-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
{template.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="template" templateId={template.id} />
</div>
)}
</div>
</div>
@@ -1,23 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { Bird, FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router';
import { Bird } from 'lucide-react';
import { 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';
@@ -27,21 +19,10 @@ 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;
@@ -53,165 +34,19 @@ export default function TemplatesPage() {
perPage: perPage,
});
const {
data: foldersData,
isLoading: isFoldersLoading,
refetch: refetchFolders,
} = trpc.folder.getFolders.useQuery({
type: FolderType.TEMPLATE,
parentId: null,
});
// Refetch the templates when the team URL changes.
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 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 items-baseline justify-between">
<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">
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
@@ -222,71 +57,38 @@ export default function TemplatesPage() {
</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>
<TemplateCreateDialog templateRootPath={templateRootPath} teamId={team?.id} />
</div>
</div>
<TemplateFolderMoveDialog
foldersData={foldersData?.folders}
folder={folderToMove}
isOpen={isMovingFolder}
onOpenChange={(open) => {
setIsMovingFolder(open);
<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} />
if (!open) {
setFolderToMove(null);
}
}}
/>
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<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);
}
}}
/>
<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>
);
}
@@ -1,230 +0,0 @@
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>
);
}
@@ -1,122 +0,0 @@
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>
);
}
@@ -1,369 +0,0 @@
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>
);
}
@@ -1,181 +0,0 @@
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,10 +5,8 @@ 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,
@@ -344,18 +342,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</Card>
{isPlatformDocument && (
<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="my-8 flex-row-reverse">
<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,10 +60,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return superLoaderJson({
isAccessAuthValid: true,
template: {
...template,
folder: null,
},
template,
directTemplateRecipient,
} as const);
}
@@ -119,7 +119,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
return (
<div
className={cn(
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)}
>
-11
View File
@@ -1,11 +0,0 @@
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,11 +22,6 @@ 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,17 +1,10 @@
import { redirect, useLoaderData } from 'react-router';
import { redirect } 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!' },
@@ -50,23 +43,11 @@ export function meta({ params: { slug } }: Route.MetaArgs) {
];
}
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,
};
}
export const loader = ({ request }: Route.LoaderArgs) => {
const userAgent = request.headers.get('User-Agent') ?? '';
if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) {
return {};
return null;
}
// Is hardcoded because this whole meta is hardcoded anyway for Documenso.
@@ -74,20 +55,5 @@ export const loader = async ({ request, params: { slug } }: 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>;
}
@@ -1,6 +1,6 @@
import sharp from 'sharp';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import type { Route } from './+types/branding.logo.team.$teamId';
@@ -24,29 +24,27 @@ export async function loader({ params }: Route.LoaderArgs) {
},
});
if (!settings || !settings.brandingLogo) {
if (!settings || !settings.brandingEnabled) {
return Response.json(
{
status: 'error',
message: 'Logo not found',
message: 'Not found',
},
{ status: 404 },
);
}
if (!settings.brandingEnabled) {
if (!settings.brandingLogo) {
return Response.json(
{
status: 'error',
message: 'Branding is not enabled',
message: 'Not found',
},
{ status: 400 },
{ status: 404 },
);
}
const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => {
console.error(e);
});
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
if (!file) {
return Response.json(
@@ -1,102 +0,0 @@
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>
);
}
@@ -1,314 +0,0 @@
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>
);
}

Some files were not shown because too many files have changed in this diff Show More