mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +10:00
Merge branch 'main' into feat/prefetch-intent-navigation-links
This commit is contained in:
@@ -5,6 +5,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -43,7 +44,7 @@ type ConfirmationDialogProps = {
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
});
|
||||
|
||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
@@ -115,7 +116,7 @@ export function AssistantConfirmationDialog({
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
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 { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DocumentDeleteDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
export const DocumentDeleteDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
status,
|
||||
documentTitle,
|
||||
canManageDocument,
|
||||
}: DocumentDeleteDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { refreshLimits } = useLimits();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const deleteMessage = msg`delete`;
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
void refreshLimits();
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
description: _(msg`"${documentTitle}" has been successfully deleted`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await onDelete?.();
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be deleted at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInputValue('');
|
||||
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
|
||||
}
|
||||
}, [open, status]);
|
||||
|
||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{canManageDocument ? (
|
||||
<Trans>
|
||||
You are about to delete <strong>"{documentTitle}"</strong>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You are about to hide <strong>"{documentTitle}"</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{canManageDocument ? (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
{match(status)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Document will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Document signing process will be cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All inserted signatures will be voided</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All recipients will be notified</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>By deleting this document, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>The document will be hidden from your account</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Recipients will still retain their copy of the document</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
<AlertDescription>
|
||||
<Trans>Please contact support if you would like to revert this action.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status !== DocumentStatus.DRAFT && canManageDocument && (
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={() => void deleteDocument({ documentId: id })}
|
||||
disabled={!isDeleteEnabled && canManageDocument}
|
||||
variant="destructive"
|
||||
>
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type DocumentDuplicateDialogProps = {
|
||||
id: string;
|
||||
token?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentDuplicateDialog = ({
|
||||
id,
|
||||
token,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentDuplicateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpcReact.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: _(msg`Document Duplicated`),
|
||||
description: _(msg`Your document has been successfully duplicated.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${documentsPath}/${id}/edit`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateEnvelope({ envelopeId: id });
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be duplicated at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isDuplicating}
|
||||
loading={isDuplicating}
|
||||
onClick={onDuplicate}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,13 +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, type Team, type User } from '@prisma/client';
|
||||
import { SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm, useWatch } 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 { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
@@ -45,10 +46,10 @@ const FORM_ID = 'resend-email';
|
||||
export type DocumentResendDialogProps = {
|
||||
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
@@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
|
||||
@@ -52,13 +52,23 @@ export const EnvelopeDeleteDialog = ({
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
const isDocument = type === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
void refreshLimits();
|
||||
|
||||
toast({
|
||||
title: t`Document deleted`,
|
||||
description: t`"${title}" has been successfully deleted`,
|
||||
title: canManageDocument
|
||||
? isDocument
|
||||
? t`Document deleted`
|
||||
: t`Template deleted`
|
||||
: isDocument
|
||||
? t`Document hidden`
|
||||
: t`Template hidden`,
|
||||
description: canManageDocument
|
||||
? t`"${title}" has been successfully deleted`
|
||||
: t`"${title}" has been successfully hidden`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -69,7 +79,9 @@ export const EnvelopeDeleteDialog = ({
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be deleted at this time. Please try again.`,
|
||||
description: isDocument
|
||||
? t`This document could not be deleted at this time. Please try again.`
|
||||
: t`This template could not be deleted at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -62,10 +63,7 @@ export type EnvelopeDistributeDialogProps = {
|
||||
export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
meta: z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().email().optional(),
|
||||
),
|
||||
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
distributionMethod: z
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -10,6 +9,7 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -41,19 +41,20 @@ export const EnvelopeDuplicateDialog = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpc.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: t`Envelope Duplicated`,
|
||||
description: t`Your envelope has been successfully duplicated.`,
|
||||
title: isDocument ? t`Document Duplicated` : t`Template Duplicated`,
|
||||
description: isDocument
|
||||
? t`Your document has been successfully duplicated.`
|
||||
: t`Your template has been successfully duplicated.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
const path =
|
||||
envelopeType === EnvelopeType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
const path = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${path}/${id}/edit`);
|
||||
setOpen(false);
|
||||
@@ -66,7 +67,9 @@ export const EnvelopeDuplicateDialog = ({
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be duplicated at this time. Please try again.`,
|
||||
description: isDocument
|
||||
? t`This document could not be duplicated at this time. Please try again.`
|
||||
: t`This template could not be duplicated at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
@@ -78,30 +81,25 @@ export const EnvelopeDuplicateDialog = ({
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
{envelopeType === EnvelopeType.DOCUMENT ? (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Document</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
<Trans>This document will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
) : (
|
||||
<Trans>This template will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
|
||||
<Trans>Duplicate</Trans>
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { AlertTriangleIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
|
||||
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
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';
|
||||
|
||||
const ZEditEnvelopeItemFormSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
});
|
||||
|
||||
type TEditEnvelopeItemFormSchema = z.infer<typeof ZEditEnvelopeItemFormSchema>;
|
||||
|
||||
/**
|
||||
* Note: This should only be visible if the envelope item is editable.
|
||||
*/
|
||||
export type EnvelopeItemEditDialogProps = {
|
||||
envelopeItem: { id: string; title: string };
|
||||
allowConfigureTitle: boolean;
|
||||
trigger: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopeItemEditDialog = ({
|
||||
envelopeItem,
|
||||
allowConfigureTitle,
|
||||
trigger,
|
||||
...props
|
||||
}: EnvelopeItemEditDialogProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { envelope, editorFields, setLocalEnvelope, isEmbedded } = useCurrentEnvelopeEditor();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [replacementFile, setReplacementFile] = useState<{ file: File; pageCount: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
|
||||
const form = useForm<TEditEnvelopeItemFormSchema>({
|
||||
resolver: zodResolver(ZEditEnvelopeItemFormSchema),
|
||||
defaultValues: {
|
||||
title: envelopeItem.title,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
|
||||
onSuccess: ({ data, fields }) => {
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === data.id
|
||||
? { ...item, documentDataId: data.documentDataId, title: data.title }
|
||||
: item,
|
||||
),
|
||||
});
|
||||
|
||||
if (fields) {
|
||||
setLocalEnvelope({ fields });
|
||||
editorFields.resetForm(fields);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const fieldsOnExcessPages =
|
||||
replacementFile !== null
|
||||
? envelope.fields.filter(
|
||||
(field) =>
|
||||
field.envelopeItemId === envelopeItem.id && field.page > replacementFile.pageCount,
|
||||
)
|
||||
: [];
|
||||
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
|
||||
if (!file || isDropping) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDropping(true);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fileData = new Uint8Array(arrayBuffer.slice(0));
|
||||
const { PDF } = await import('@libpdf/core');
|
||||
const pdfDoc = await PDF.load(fileData);
|
||||
|
||||
setReplacementFile({
|
||||
file,
|
||||
pageCount: pdfDoc.getPageCount(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: t`Failed to read file`,
|
||||
description: t`The file is not a valid PDF.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsDropping(false);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: { 'application/pdf': ['.pdf'] },
|
||||
maxFiles: 1,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
disabled: form.formState.isSubmitting,
|
||||
onDrop: (files) => void onFileDrop(files),
|
||||
onDropRejected: onFileDropRejected,
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TEditEnvelopeItemFormSchema) => {
|
||||
if (isDropping || !replacementFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { file, pageCount } = replacementFile;
|
||||
|
||||
if (isEmbedded) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fileData = new Uint8Array(arrayBuffer.slice(0));
|
||||
|
||||
const remainingFields = envelope.fields.filter(
|
||||
(field) => field.envelopeItemId !== envelopeItem.id || field.page <= pageCount,
|
||||
);
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === envelopeItem.id ? { ...item, title: data.title, data: fileData } : item,
|
||||
),
|
||||
fields: remainingFields,
|
||||
});
|
||||
|
||||
editorFields.resetForm(remainingFields);
|
||||
} else {
|
||||
const payload = {
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
title: data.title,
|
||||
} satisfies TReplaceEnvelopeItemPdfPayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
await replaceEnvelopeItemPdf(formData);
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Failed to update item`,
|
||||
description: t`Something went wrong while updating the envelope item.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset({ title: envelopeItem.title });
|
||||
setReplacementFile(null);
|
||||
setIsDropping(false);
|
||||
}
|
||||
}, [isOpen, form, envelopeItem.title]);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={isOpen}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Edit Item</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Update the title or replace the PDF file.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Document Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="envelope-item-edit-title-input"
|
||||
placeholder={t`Document Title`}
|
||||
disabled={!allowConfigureTitle}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Replace PDF</Trans>
|
||||
</FormLabel>
|
||||
|
||||
{replacementFile ? (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div
|
||||
data-testid="envelope-item-edit-selected-file"
|
||||
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center space-x-2">
|
||||
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{replacementFile.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(replacementFile.file.size)}
|
||||
{isDropping ? ' · …' : ' · '}
|
||||
{!isDropping && replacementFile.pageCount !== null && (
|
||||
<Plural
|
||||
one="1 page"
|
||||
other="# pages"
|
||||
value={replacementFile.pageCount}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="envelope-item-edit-clear-file"
|
||||
onClick={() => {
|
||||
setReplacementFile(null);
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fieldsOnExcessPages.length > 0 && (
|
||||
<Alert variant="warning" padding="tight">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription data-testid="envelope-item-edit-field-warning">
|
||||
<Plural
|
||||
one="1 field will be deleted because the new PDF has fewer pages than the current one."
|
||||
other="# fields will be deleted because the new PDF has fewer pages than the current one."
|
||||
value={fieldsOnExcessPages.length}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="envelope-item-edit-dropzone"
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'mt-1.5 flex cursor-pointer items-center justify-center rounded-md border border-dashed border-border px-4 py-4 transition-colors',
|
||||
isDragActive
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'hover:border-muted-foreground/50 hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<UploadIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Drop PDF here or click to select</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={isDropping || !replacementFile}
|
||||
data-testid="envelope-item-edit-update-button"
|
||||
>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,12 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -38,7 +39,7 @@ import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
export type EnvelopeRedistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
};
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeRenameDialogProps = {
|
||||
id: string;
|
||||
initialTitle: string;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onSuccess?: () => Promise<void>;
|
||||
envelopeType?: 'document' | 'template';
|
||||
};
|
||||
|
||||
export const EnvelopeRenameDialog = ({
|
||||
id,
|
||||
initialTitle,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
envelopeType = 'document',
|
||||
}: EnvelopeRenameDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(initialTitle);
|
||||
}
|
||||
}, [open, initialTitle]);
|
||||
|
||||
const isTemplate = envelopeType === 'template';
|
||||
|
||||
const { mutate: updateEnvelope, isPending } = trpcReact.envelope.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await onSuccess?.();
|
||||
|
||||
toast({
|
||||
title: isTemplate ? t`Template Renamed` : t`Document Renamed`,
|
||||
description: isTemplate
|
||||
? t`Your template has been successfully renamed.`
|
||||
: t`Your document has been successfully renamed.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
description: t`Something went wrong. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
|
||||
const onRename = () => {
|
||||
if (!trimmedTitle || trimmedTitle === initialTitle) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateEnvelope({
|
||||
envelopeId: id,
|
||||
data: {
|
||||
title: trimmedTitle,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isTemplate ? <Trans>Rename Template</Trans> : <Trans>Rename Document</Trans>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
<Label htmlFor="title" className="sr-only">
|
||||
<Trans>Title</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
placeholder={t`Enter a new title`}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isPending}
|
||||
maxLength={DOCUMENT_TITLE_MAX_LENGTH}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending || !trimmedTitle || trimmedTitle === initialTitle}
|
||||
loading={isPending}
|
||||
onClick={() => void onRename()}
|
||||
>
|
||||
<Trans>Rename</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type EnvelopeSaveAsTemplateDialogProps = {
|
||||
envelopeId: string;
|
||||
trigger?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnvelopeSaveAsTemplateDialog = ({
|
||||
envelopeId,
|
||||
trigger,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: EnvelopeSaveAsTemplateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
includeRecipients: true,
|
||||
includeFields: true,
|
||||
},
|
||||
});
|
||||
|
||||
const includeRecipients = form.watch('includeRecipients');
|
||||
|
||||
const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: t`Template Created`,
|
||||
description: t`Your document has been saved as a template.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${templatesPath}/${id}/edit`);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { includeRecipients, includeFields } = form.getValues();
|
||||
|
||||
try {
|
||||
await saveAsTemplate({
|
||||
envelopeId,
|
||||
includeRecipients,
|
||||
includeFields: includeRecipients && includeFields,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be saved as a template at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(value);
|
||||
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Save as Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Create a template from this document.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="includeRecipients"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="envelopeIncludeRecipients"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked === true);
|
||||
|
||||
if (!checked) {
|
||||
form.setValue('includeFields', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="envelopeIncludeRecipients">
|
||||
<Trans>Include Recipients</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="includeFields"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="envelopeIncludeFields"
|
||||
checked={field.value}
|
||||
disabled={!includeRecipients}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="envelopeIncludeFields"
|
||||
className={!includeRecipients ? 'opacity-50' : ''}
|
||||
>
|
||||
<Trans>Include Fields</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" loading={isPending} onClick={onSubmit}>
|
||||
<Trans>Save as Template</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -40,13 +40,21 @@ type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||
export type FolderCreateDialogProps = {
|
||||
type: FolderType;
|
||||
trigger?: React.ReactNode;
|
||||
parentFolderId?: string | null;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
|
||||
export const FolderCreateDialog = ({
|
||||
type,
|
||||
trigger,
|
||||
parentFolderId,
|
||||
...props
|
||||
}: FolderCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const parentId = parentFolderId ?? folderId;
|
||||
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||
@@ -62,7 +70,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
||||
try {
|
||||
await createFolder({
|
||||
name: data.name,
|
||||
parentId: folderId,
|
||||
parentId,
|
||||
type,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '@documenso/lib/constants/app'
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -94,7 +95,7 @@ type TabTypes = 'INDIVIDUAL' | 'BULK';
|
||||
|
||||
const ZImportOrganisationMemberSchema = z.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
email: zEmail(),
|
||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||
}),
|
||||
);
|
||||
@@ -329,12 +330,12 @@ export const OrganisationMemberInviteDialog = ({
|
||||
onValueChange={(value) => setInvitationType(value as TabTypes)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
|
||||
<TabsTrigger value="INDIVIDUAL" className="w-full hover:text-foreground">
|
||||
<MailIcon size={20} className="mr-2" />
|
||||
<Trans>Invite Members</Trans>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
|
||||
<TabsTrigger value="BULK" className="w-full hover:text-foreground">
|
||||
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -382,7 +383,7 @@ export const OrganisationMemberInviteDialog = ({
|
||||
)}
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -447,7 +448,7 @@ export const OrganisationMemberInviteDialog = ({
|
||||
<div className="mt-4 space-y-4">
|
||||
<Card gradient className="h-32">
|
||||
<CardContent
|
||||
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
|
||||
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,10 +25,7 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldEmailFormSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.min(1, { message: msg`Email is required`.id }),
|
||||
email: zEmail().min(1, { message: msg`Email is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||
|
||||
@@ -10,8 +10,10 @@ import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -73,8 +75,14 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const canInviteOrganisationMembers = canExecuteOrganisationAction(
|
||||
'MANAGE_ORGANISATION',
|
||||
organisation.currentOrganisationRole,
|
||||
);
|
||||
|
||||
const form = useForm<TAddTeamMembersFormSchema>({
|
||||
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
||||
defaultValues: {
|
||||
@@ -106,7 +114,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
|
||||
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
|
||||
if (members.length === 0) {
|
||||
if (hasNoAvailableMembers) {
|
||||
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
|
||||
setInviteDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
@@ -231,7 +239,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && form.getValues('members').length === 0) {
|
||||
e.preventDefault();
|
||||
if (hasNoAvailableMembers) {
|
||||
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
|
||||
setInviteDialogOpen(true);
|
||||
}
|
||||
// Don't show toast - the disabled Next button already communicates this
|
||||
@@ -260,21 +268,32 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
<Trans>No organisation members available</Trans>
|
||||
</h3>
|
||||
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
To add members to this team, you must first add them to the
|
||||
organisation.
|
||||
</Trans>
|
||||
{canInviteOrganisationMembers ? (
|
||||
<Trans>
|
||||
To add members to this team, you must first add them to the
|
||||
organisation.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To add members to this team, they must first be invited to the
|
||||
organisation. Only organisation admins and managers can invite
|
||||
new members — please contact one of them to invite members on
|
||||
your behalf.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
<OrganisationMemberInviteDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
trigger={
|
||||
<Button type="button" variant="default">
|
||||
<UserPlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Invite organisation members</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{canInviteOrganisationMembers && (
|
||||
<OrganisationMemberInviteDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
trigger={
|
||||
<Button type="button" variant="default">
|
||||
<UserPlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Invite organisation members</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MultiSelectCombobox
|
||||
@@ -310,30 +329,32 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
<Trans>Select members to add to this team</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<Alert
|
||||
variant="neutral"
|
||||
className="mt-2 flex items-center gap-2 space-y-0"
|
||||
>
|
||||
<div>
|
||||
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<AlertDescription className="mt-0 flex-1">
|
||||
<Trans>Can't find someone?</Trans>{' '}
|
||||
<OrganisationMemberInviteDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
<Trans>Invite them to the organisation first</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{canInviteOrganisationMembers && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
className="mt-2 flex items-center gap-2 space-y-0"
|
||||
>
|
||||
<div>
|
||||
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<AlertDescription className="mt-0 flex-1">
|
||||
<Trans>Can't find someone?</Trans>{' '}
|
||||
<OrganisationMemberInviteDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
<Trans>Invite them to the organisation first</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormItem>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
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 TemplateDeleteDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const TemplateDeleteDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
}: TemplateDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await onDelete?.();
|
||||
|
||||
toast({
|
||||
title: _(msg`Template deleted`),
|
||||
description: _(msg`Your template has been successfully deleted.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This template could not be deleted at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Do you want to delete this template?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Please note that this action is irreversible. Once confirmed, your template will be
|
||||
permanently deleted.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isPending}
|
||||
onClick={async () => deleteTemplate({ templateId: id })}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||
import { RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||
import {
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
@@ -21,6 +21,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
@@ -52,7 +53,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
type TemplateDirectLinkDialogProps = {
|
||||
templateId: number;
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
trigger?: React.ReactNode;
|
||||
onCreateSuccess?: () => Promise<void> | void;
|
||||
onDeleteSuccess?: () => Promise<void> | void;
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
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 TemplateDuplicateDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const TemplateDuplicateDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TemplateDuplicateDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: duplicateTemplate, isPending } =
|
||||
trpcReact.template.duplicateTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Template duplicated`),
|
||||
description: _(msg`Your template has been duplicated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while duplicating template.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Do you want to duplicate this template?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="pt-2">
|
||||
<Trans>Your template will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={async () =>
|
||||
duplicateTemplate({
|
||||
templateId: id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ 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 } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -79,7 +78,7 @@ export type TemplateUseDialogProps = {
|
||||
envelopeId: string;
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
documentDistributionMethod?: DocumentDistributionMethod;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
|
||||
@@ -60,11 +60,11 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-foreground mb-1 text-lg font-medium">
|
||||
<h3 className="mb-1 text-lg font-medium text-foreground">
|
||||
<Trans>Advanced Settings</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
<Trans>Configure additional options and preferences</Trans>
|
||||
</p>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
className="w-full bg-background"
|
||||
emptySelectionPlaceholder={t`Select signature types`}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -204,7 +204,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Add a URL to redirect the user to once the document is signed
|
||||
</Trans>
|
||||
@@ -279,7 +279,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
<FormControl>
|
||||
<Input
|
||||
id="subject"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
disabled={isSubmitting || !isEmailDistribution}
|
||||
{...field}
|
||||
/>
|
||||
@@ -302,7 +302,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id="message"
|
||||
className="bg-background mt-2 h-32 resize-none"
|
||||
className="mt-2 h-32 resize-none bg-background"
|
||||
disabled={isSubmitting || !isEmailDistribution}
|
||||
{...field}
|
||||
/>
|
||||
|
||||
@@ -4,10 +4,11 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Cloud, FileText, Loader, X } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -82,10 +83,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
}
|
||||
};
|
||||
|
||||
const onDropRejected = () => {
|
||||
const onDropRejected = (fileRejections: FileRejection[]) => {
|
||||
toast({
|
||||
title: _(msg`Your document failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
description: _(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -144,7 +145,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
|
||||
'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background transition',
|
||||
{
|
||||
'border-primary/50 bg-primary/5': isDragActive,
|
||||
'hover:bg-muted/30':
|
||||
@@ -193,21 +194,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
</FormControl>
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
|
||||
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{documentData.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatFileSize(documentData.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ZDocumentMetaLanguageSchema,
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||
|
||||
// Define the schema for configuration
|
||||
@@ -19,7 +20,7 @@ export const ZConfigureEmbedFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
formId: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||
signingOrder: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -13,6 +13,7 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
@@ -105,7 +106,7 @@ export const ConfigureFieldsView = ({
|
||||
}, [configData.documentData, envelopeItem, presignToken]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
return configData.signers.map<Recipient>((signer, index) => ({
|
||||
return configData.signers.map<TRecipientLite>((signer, index) => ({
|
||||
id: signer.nativeId || index,
|
||||
name: signer.name || '',
|
||||
email: signer.email || '',
|
||||
@@ -128,7 +129,7 @@ export const ConfigureFieldsView = ({
|
||||
}));
|
||||
}, [configData.signers]);
|
||||
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<TRecipientLite | null>(
|
||||
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
|
||||
);
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader } from 'lucide-react';
|
||||
|
||||
export const EmbedClientLoading = () => {
|
||||
return (
|
||||
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
||||
<div className="fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center bg-background">
|
||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
<span>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -33,6 +36,7 @@ import type {
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@@ -92,6 +96,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
@@ -205,6 +210,14 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { success: isEmailValid } = zEmail().safeParse(email);
|
||||
|
||||
if (!isEmailValid) {
|
||||
setEmailError(_(msg`A valid email is required`));
|
||||
setIsExpanded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
||||
|
||||
if (directTemplateExternalId) {
|
||||
@@ -290,12 +303,19 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
|
||||
void dynamicActivate(data.language).finally(() => {
|
||||
setHasFinishedInit(true);
|
||||
});
|
||||
} else {
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the two setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -433,11 +453,23 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="mt-2 bg-background"
|
||||
className={cn(
|
||||
'mt-2 bg-background',
|
||||
emailError && 'border-destructive ring-2 ring-destructive/20',
|
||||
)}
|
||||
disabled={isEmailLocked}
|
||||
value={email}
|
||||
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
|
||||
onChange={(e) => {
|
||||
if (!isEmailLocked) {
|
||||
setEmail(e.target.value.trim());
|
||||
setEmailError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{emailError && (
|
||||
<p className="mt-2 text-xs font-medium text-destructive">{emailError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSignatureField && (
|
||||
|
||||
@@ -8,11 +8,13 @@ import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -232,12 +234,19 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
|
||||
void dynamicActivate(data.language).finally(() => {
|
||||
setHasFinishedInit(true);
|
||||
});
|
||||
} else {
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the two setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
@@ -162,12 +164,19 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
|
||||
void dynamicActivate(data.language).finally(() => {
|
||||
setHasFinishedInit(true);
|
||||
});
|
||||
} else {
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -98,7 +98,7 @@ export function BrandingPreferencesForm({
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||
|
||||
setPreviewUrl(logoUrl);
|
||||
setPreviewUrl(logoUrl + '?v=' + Date.now());
|
||||
setHasLoadedPreview(true);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export function BrandingPreferencesForm({
|
||||
/>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-y-4">
|
||||
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9998]" />}
|
||||
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -185,7 +185,7 @@ export function BrandingPreferencesForm({
|
||||
</FormLabel>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
|
||||
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
@@ -193,12 +193,12 @@ export function BrandingPreferencesForm({
|
||||
className="h-full w-full object-contain p-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-muted/20 text-sm text-muted-foreground dark:bg-muted">
|
||||
<Trans>Please upload a logo</Trans>
|
||||
|
||||
{!hasLoadedPreview && (
|
||||
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-muted dark:bg-muted">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -243,7 +243,7 @@ export function BrandingPreferencesForm({
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-destructive text-xs"
|
||||
className="text-xs text-destructive"
|
||||
onClick={() => {
|
||||
setPreviewUrl('');
|
||||
onChange(null);
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
type TEnvelopeExpirationPeriod,
|
||||
ZEnvelopeExpirationPeriod,
|
||||
} from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
type TEnvelopeReminderSettings,
|
||||
ZEnvelopeReminderSettings,
|
||||
} from '@documenso/lib/constants/envelope-reminder';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
@@ -32,6 +36,7 @@ import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -76,6 +81,7 @@ export type TDocumentPreferencesFormSchema = {
|
||||
delegateDocumentOwnership: boolean | null;
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
||||
reminderSettings: TEnvelopeReminderSettings | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
@@ -94,6 +100,7 @@ type SettingsSubset = Pick<
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
| 'envelopeExpirationPeriod'
|
||||
| 'reminderSettings'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
@@ -134,6 +141,7 @@ export const DocumentPreferencesForm = ({
|
||||
delegateDocumentOwnership: z.boolean().nullable(),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
@@ -155,6 +163,7 @@ export const DocumentPreferencesForm = ({
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: settings.reminderSettings ?? null,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
@@ -707,6 +716,35 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reminderSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Signing Reminders</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ReminderSettingsPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls when and how often reminder emails are sent to recipients who have not
|
||||
yet completed signing.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
ZDocumentEmailSettingsSchema,
|
||||
} from '@documenso/lib/types/document-email';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
|
||||
const ZEmailPreferencesFormSchema = z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.string().email().nullable(),
|
||||
emailReplyTo: zEmail().nullable(),
|
||||
// emailReplyToName: z.string(),
|
||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
|
||||
});
|
||||
@@ -219,7 +220,8 @@ export const EmailPreferencesForm = ({
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the default email settings when new documents or templates are created
|
||||
Controls the default email settings when new documents or templates are created.
|
||||
Updating these settings will not affect existing documents or templates.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -21,7 +22,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZForgotPasswordFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
});
|
||||
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZSendConfirmationEmailFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
});
|
||||
|
||||
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
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 { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { KeyRoundIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -16,7 +18,9 @@ import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -58,7 +62,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
||||
const LOGIN_REDIRECT_PATH = '/';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
password: ZCurrentPasswordSchema,
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
@@ -100,6 +104,10 @@ export const SignInForm = ({
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
const redirectPath = useMemo(() => {
|
||||
@@ -216,6 +224,7 @@ export const SignInForm = ({
|
||||
password,
|
||||
totpCode,
|
||||
backupCode,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
redirectPath,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -250,6 +259,10 @@ export const SignInForm = ({
|
||||
AuthenticationErrorCode.InvalidTwoFactorCode,
|
||||
() => msg`The two-factor authentication code provided is incorrect.`,
|
||||
)
|
||||
.with(
|
||||
AppErrorCode.INVALID_CAPTCHA,
|
||||
() => msg`We were unable to verify that you're human. Please try again.`,
|
||||
)
|
||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||
|
||||
toast({
|
||||
@@ -257,6 +270,9 @@ export const SignInForm = ({
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -377,6 +393,18 @@ export const SignInForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'invisible',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
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 { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FaIdCardClip } from 'react-icons/fa6';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@@ -15,6 +17,8 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -39,7 +43,7 @@ export const ZSignUpFormSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
||||
})
|
||||
@@ -88,6 +92,11 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
@@ -110,6 +119,7 @@ export const SignUpForm = ({
|
||||
email,
|
||||
password,
|
||||
signature,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
});
|
||||
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
@@ -138,6 +148,9 @@ export const SignUpForm = ({
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,13 +258,7 @@ export const SignUpForm = ({
|
||||
className="flex w-full flex-1 flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
hasSocialAuthEnabled && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -323,6 +330,19 @@ export const SignUpForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type DetailsCardProps = {
|
||||
label: ReactNode;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const DetailsCard = ({ label, action, children }: DetailsCardProps) => {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<div className="flex min-h-9 items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{action ?? null}
|
||||
</div>
|
||||
<div className="mt-2 min-h-9">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type DetailsValueProps = {
|
||||
children: ReactNode;
|
||||
isMono?: boolean;
|
||||
isSelectable?: boolean;
|
||||
};
|
||||
|
||||
export const DetailsValue = ({
|
||||
children,
|
||||
isMono = true,
|
||||
isSelectable = false,
|
||||
}: DetailsValueProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-10 items-center break-all rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground',
|
||||
isMono && 'font-mono',
|
||||
isSelectable && 'select-all',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
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 { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_VISIBILITY } from '@documenso/lib/constants/document-visibility';
|
||||
import {
|
||||
type TDocumentEmailSettings,
|
||||
ZDocumentEmailSettingsSchema,
|
||||
} from '@documenso/lib/types/document-email';
|
||||
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
|
||||
const EMAIL_SETTINGS_LABELS: Record<keyof TDocumentEmailSettings, MessageDescriptor> = {
|
||||
recipientSigningRequest: msg`Recipient signing request`,
|
||||
recipientRemoved: msg`Recipient removed`,
|
||||
recipientSigned: msg`Recipient signed`,
|
||||
documentPending: msg`Document pending`,
|
||||
documentCompleted: msg`Document completed`,
|
||||
documentDeleted: msg`Document deleted`,
|
||||
ownerDocumentCompleted: msg`Owner document completed`,
|
||||
ownerRecipientExpired: msg`Owner recipient expired`,
|
||||
ownerDocumentCreated: msg`Owner document created`,
|
||||
};
|
||||
|
||||
const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocumentEmailSettings)[];
|
||||
|
||||
type AdminGlobalSettingsSectionProps = {
|
||||
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
||||
isTeam?: boolean;
|
||||
};
|
||||
|
||||
export const AdminGlobalSettingsSection = ({
|
||||
settings,
|
||||
isTeam = false,
|
||||
}: AdminGlobalSettingsSectionProps) => {
|
||||
const { _ } = useLingui();
|
||||
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const brandingTextValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined || value.trim() === '') {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const booleanValue = (value: boolean | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
|
||||
};
|
||||
|
||||
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(
|
||||
settings.emailDocumentSettings,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
||||
<DetailsValue>
|
||||
{settings.documentVisibility != null
|
||||
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
|
||||
: notSetLabel}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document language</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Date format</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
||||
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
{isTeam && parsedEmailSettings.success && (
|
||||
<DetailsCard label={<Trans>Email document settings</Trans>}>
|
||||
<div className="mt-1 space-y-1 pb-2 pr-3 text-xs">
|
||||
{emailSettingsKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">{_(EMAIL_SETTINGS_LABELS[key])}</span>
|
||||
<span>
|
||||
{parsedEmailSettings.data[key] ? <Trans>On</Trans> : <Trans>Off</Trans>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailsCard>
|
||||
)}
|
||||
|
||||
<DetailsCard label={<Trans>AI features</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -14,7 +14,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: Recipient;
|
||||
recipient: TRecipientLite;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { z } from 'zod';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -37,7 +38,7 @@ export const ZClaimAccountFormSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
password: ZPasswordSchema,
|
||||
})
|
||||
.refine(
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import {
|
||||
DocumentReadOnlyFields,
|
||||
mapFieldsWithRecipients,
|
||||
@@ -33,7 +34,7 @@ import { useStep } from '@documenso/ui/primitives/stepper';
|
||||
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
|
||||
const ZDirectTemplateConfigureFormSchema = z.object({
|
||||
email: z.string().email('Email is invalid'),
|
||||
email: zEmail('Email is invalid'),
|
||||
});
|
||||
|
||||
export type TDirectTemplateConfigureFormSchema = z.infer<typeof ZDirectTemplateConfigureFormSchema>;
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
|
||||
if (
|
||||
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
|
||||
user?.email == recipient.email
|
||||
user?.email === recipient.email
|
||||
) {
|
||||
return {
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
|
||||
@@ -167,7 +167,7 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
<div className="max-w-[50ch] text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
When you sign a document, we can automatically fill in and sign the following fields
|
||||
|
||||
+3
-2
@@ -14,6 +14,7 @@ import {
|
||||
ZDocumentAccessAuthSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -68,7 +69,7 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
|
||||
});
|
||||
|
||||
@@ -76,7 +77,7 @@ type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
|
||||
const ZDirectRecipientFormSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
});
|
||||
|
||||
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
|
||||
|
||||
+10
-10
@@ -131,16 +131,16 @@ export const DocumentSigningFieldContainer = ({
|
||||
|
||||
return (
|
||||
<FieldRootContainer
|
||||
color={getRecipientColorStyles(field.fieldMeta?.readOnly ? 'readOnly' : 0)}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
color={getRecipientColorStyles(field.fieldMeta?.readOnly ? 'readOnly' : 0)}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -83,6 +86,54 @@ export interface EnvelopeSigningProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject prefilled date fields for the current recipient.
|
||||
*
|
||||
* The dates are filled in correctly when the recipient "completes" the document.
|
||||
*/
|
||||
const prefillDateFields = (data: EnvelopeForSigningResponse): EnvelopeForSigningResponse => {
|
||||
const { timezone, dateFormat } = data.envelope.documentMeta;
|
||||
|
||||
const formattedDate = DateTime.now()
|
||||
.setZone(timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
||||
.toFormat(dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
|
||||
const prefillField = <
|
||||
T extends { type: FieldType; inserted: boolean; customText: string; fieldMeta: unknown },
|
||||
>(
|
||||
field: T,
|
||||
): T => {
|
||||
if (field.type !== FieldType.DATE || field.inserted) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
customText: formattedDate,
|
||||
inserted: true,
|
||||
fieldMeta: {
|
||||
...(typeof field.fieldMeta === 'object' ? field.fieldMeta : {}),
|
||||
readOnly: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
...data,
|
||||
envelope: {
|
||||
...data.envelope,
|
||||
recipients: data.envelope.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
fields: recipient.fields.map(prefillField),
|
||||
})),
|
||||
},
|
||||
recipient: {
|
||||
...data.recipient,
|
||||
fields: data.recipient.fields.map(prefillField),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const EnvelopeSigningProvider = ({
|
||||
fullName: initialFullName,
|
||||
email: initialEmail,
|
||||
@@ -90,7 +141,7 @@ export const EnvelopeSigningProvider = ({
|
||||
envelopeData: initialEnvelopeData,
|
||||
children,
|
||||
}: EnvelopeSigningProviderProps) => {
|
||||
const [envelopeData, setEnvelopeData] = useState(initialEnvelopeData);
|
||||
const [envelopeData, setEnvelopeData] = useState(() => prefillDateFields(initialEnvelopeData));
|
||||
|
||||
const { envelope, recipient } = envelopeData;
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
FileOutputIcon,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
ScrollTextIcon,
|
||||
Share,
|
||||
Trash2,
|
||||
@@ -18,8 +19,12 @@ import { Link, useNavigate } from 'react-router';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import {
|
||||
getEnvelopeItemPermissions,
|
||||
mapSecondaryIdToDocumentId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -28,12 +33,13 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -43,14 +49,14 @@ export type DocumentPageViewDropdownProps = {
|
||||
|
||||
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
|
||||
const { user } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
|
||||
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
@@ -62,13 +68,15 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
const isCurrentTeamDocument = team && envelope.teamId === team.id;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const { canTitleBeChanged } = getEnvelopeItemPermissions(envelope, []);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
|
||||
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -86,6 +94,13 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canManageDocument && canTitleBeChanged && (
|
||||
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Rename</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
@@ -108,15 +123,42 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
<EnvelopeDuplicateDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={isDeleted}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
<EnvelopeDeleteDialog
|
||||
id={envelope.id}
|
||||
type={EnvelopeType.DOCUMENT}
|
||||
status={envelope.status}
|
||||
title={envelope.title}
|
||||
canManageDocument={canManageDocument}
|
||||
onDelete={() => {
|
||||
void navigate(documentsPath);
|
||||
}}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild disabled={isDeleted} onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Share</Trans>
|
||||
@@ -159,26 +201,21 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<DocumentDeleteDialog
|
||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
status={envelope.status}
|
||||
documentTitle={envelope.title}
|
||||
open={isDeleteDialogOpen}
|
||||
canManageDocument={canManageDocument}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={() => {
|
||||
void navigate(documentsPath);
|
||||
}}
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={envelope.id}
|
||||
open={isSaveAsTemplateDialogOpen}
|
||||
onOpenChange={setSaveAsTemplateDialogOpen}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DocumentDuplicateDialog
|
||||
id={envelope.id}
|
||||
token={recipient?.token}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<EnvelopeRenameDialog
|
||||
id={envelope.id}
|
||||
initialTitle={envelope.title}
|
||||
open={isRenameDialogOpen}
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
onSuccess={async () => {
|
||||
await trpcUtils.envelope.get.invalidate();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
@@ -11,6 +10,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -29,7 +29,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentRecipientLinkCopyDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const DocumentRecipientLinkCopyDialog = ({
|
||||
@@ -88,7 +88,7 @@ export const DocumentRecipientLinkCopyDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="text-muted-foreground divide-y rounded-lg border">
|
||||
<ul className="divide-y rounded-lg border text-muted-foreground">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -99,9 +99,9 @@ export const DocumentRecipientLinkCopyDialog = ({
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type { FileRejection } from 'react-dropzone';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -11,13 +12,13 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } 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 { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
|
||||
import {
|
||||
@@ -162,10 +163,10 @@ export const DocumentUploadButtonLegacy = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
toast({
|
||||
title: _(msg`Your document failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
description: _(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -28,14 +28,17 @@ import {
|
||||
type TSignatureFieldMeta,
|
||||
type TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
||||
@@ -85,6 +88,11 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
[editorFields.selectedField],
|
||||
@@ -157,7 +165,39 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
||||
<EnvelopeRendererFileSelector
|
||||
className="px-0"
|
||||
fields={editorFields.localFields}
|
||||
renderItemAction={
|
||||
editorConfig.envelopeItems !== null &&
|
||||
editorConfig.envelopeItems.allowReplace &&
|
||||
envelopeItemPermissions.canFileBeChanged
|
||||
? (item) => (
|
||||
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0',
|
||||
{ 'bg-green-500': currentEnvelopeItem?.id === item.id },
|
||||
)}
|
||||
/>
|
||||
<EnvelopeItemEditDialog
|
||||
envelopeItem={item}
|
||||
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
|
||||
trigger={
|
||||
<span
|
||||
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`envelope-item-edit-button-${item.id}`}
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
@@ -297,32 +337,42 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
|
||||
{selectedField.id && (
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Field ID:</Trans>
|
||||
</span>{' '}
|
||||
{selectedField.id}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Recipient ID:</Trans>
|
||||
</span>{' '}
|
||||
{selectedField.recipientId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Pos X:</Trans>
|
||||
</span>
|
||||
|
||||
</span>{' '}
|
||||
{selectedField.positionX.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Pos Y:</Trans>
|
||||
</span>
|
||||
|
||||
</span>{' '}
|
||||
{selectedField.positionY.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Width:</Trans>
|
||||
</span>
|
||||
|
||||
</span>{' '}
|
||||
{selectedField.width.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Height:</Trans>
|
||||
</span>
|
||||
|
||||
</span>{' '}
|
||||
{selectedField.height.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
Building2Icon,
|
||||
Globe2Icon,
|
||||
LockIcon,
|
||||
RefreshCwIcon,
|
||||
@@ -12,7 +15,10 @@ import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import {
|
||||
getEnvelopeItemPermissions,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
@@ -49,6 +55,11 @@ export default function EnvelopeEditorHeader() {
|
||||
actions: { allowAttachments, allowDistributing },
|
||||
} = editorConfig;
|
||||
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
);
|
||||
|
||||
const handleCreateEmbeddedEnvelope = async () => {
|
||||
const latestEnvelope = await flushAutosave();
|
||||
|
||||
@@ -80,7 +91,8 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<EnvelopeItemTitleInput
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
|
||||
dataTestId="envelope-title-input"
|
||||
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
|
||||
value={envelope.title}
|
||||
onChange={(title) => {
|
||||
updateEnvelope({
|
||||
@@ -94,12 +106,19 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<>
|
||||
{envelope.templateType === 'PRIVATE' ? (
|
||||
{envelope.templateType === TemplateType.PRIVATE && (
|
||||
<Badge variant="secondary">
|
||||
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
|
||||
<Trans>Private Template</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
)}
|
||||
{envelope.templateType === TemplateType.ORGANISATION && (
|
||||
<Badge variant="orange">
|
||||
<Building2Icon className="mr-2 size-4" />
|
||||
<Trans>Organisation Template</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{envelope.templateType === TemplateType.PUBLIC && (
|
||||
<Badge variant="default">
|
||||
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Public Template</Trans>
|
||||
|
||||
+96
-7
@@ -8,10 +8,12 @@ import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentVisibility,
|
||||
EnvelopeType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
TemplateType,
|
||||
} from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
import { BellRingIcon, InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
DOCUMENT_SIGNATURE_TYPES,
|
||||
} from '@documenso/lib/constants/document';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
@@ -49,6 +52,7 @@ import {
|
||||
canAccessTeamDocument,
|
||||
extractTeamSignatureSettings,
|
||||
} from '@documenso/lib/utils/teams';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||
import {
|
||||
@@ -66,6 +70,11 @@ import {
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import {
|
||||
TemplateTypeSelect,
|
||||
TemplateTypeTooltip,
|
||||
} from '@documenso/ui/components/template/template-type-select';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
@@ -102,6 +111,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export const ZAddSettingsFormSchema = z.object({
|
||||
templateType: z.nativeEnum(TemplateType).optional(),
|
||||
externalId: z.string().optional(),
|
||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||
globalAccessAuth: z
|
||||
@@ -131,19 +141,17 @@ export const ZAddSettingsFormSchema = z.object({
|
||||
.optional()
|
||||
.default('en'),
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().email().optional(),
|
||||
),
|
||||
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
|
||||
emailSettings: ZDocumentEmailSettingsSchema,
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'email' | 'security';
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -152,6 +160,12 @@ const tabs = [
|
||||
icon: SettingsIcon,
|
||||
description: msg`Configure document settings and options before sending.`,
|
||||
},
|
||||
{
|
||||
id: 'reminders',
|
||||
title: msg`Reminders`,
|
||||
icon: BellRingIcon,
|
||||
description: msg`Configure signing reminder settings for the document.`,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: msg`Email`,
|
||||
@@ -196,6 +210,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
const createDefaultValues = () => {
|
||||
return {
|
||||
templateType: envelope.templateType || TemplateType.PRIVATE,
|
||||
externalId: envelope.externalId || '',
|
||||
visibility: envelope.visibility || '',
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
||||
@@ -216,6 +231,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -227,7 +243,10 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
const envelopeHasBeenSent =
|
||||
envelope.type === EnvelopeType.DOCUMENT &&
|
||||
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
|
||||
envelope.recipients.some(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
|
||||
);
|
||||
|
||||
const emailSettings = form.watch('meta.emailSettings');
|
||||
|
||||
@@ -261,6 +280,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
subject,
|
||||
emailReplyTo,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
@@ -270,6 +290,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
try {
|
||||
await updateEnvelopeAsync({
|
||||
data: {
|
||||
templateType: envelope.type === EnvelopeType.TEMPLATE ? data.templateType : undefined,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
@@ -290,6 +311,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -371,6 +393,10 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tab.id === 'reminders' && !settings.allowConfigureReminders) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={tab.id}
|
||||
@@ -606,6 +632,31 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="templateType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Template type</Trans>
|
||||
<TemplateTypeTooltip
|
||||
organisationTeamCount={organisation.teams.length}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<TemplateTypeSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settings.allowConfigureDistribution && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -716,6 +767,44 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
)}
|
||||
</>
|
||||
))
|
||||
.with(
|
||||
{ activeTab: 'reminders', settings: { allowConfigureReminders: true } },
|
||||
() => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.reminderSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Signing Reminders</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Configure when and how often reminder emails are sent to
|
||||
recipients who have not yet completed signing. Uses the team
|
||||
default when set to inherit.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ReminderSettingsPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
|
||||
() => (
|
||||
|
||||
+157
-21
@@ -4,10 +4,8 @@ import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2Icon, PencilIcon, XIcon } from 'lucide-react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
|
||||
@@ -16,10 +14,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -41,13 +42,14 @@ type LocalFile = {
|
||||
title: string;
|
||||
envelopeItemId: string | null;
|
||||
isUploading: boolean;
|
||||
isReplacing: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorUploadPage = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -72,10 +74,36 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
title: item.title,
|
||||
envelopeItemId: item.id,
|
||||
isUploading: false,
|
||||
isReplacing: false,
|
||||
isError: false,
|
||||
})),
|
||||
);
|
||||
|
||||
const replacingItemIdRef = useRef<string | null>(null);
|
||||
|
||||
const { open: openReplaceFilePicker, getInputProps: getReplaceInputProps } = useDropzone({
|
||||
accept: { 'application/pdf': ['.pdf'] },
|
||||
maxFiles: 1,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
multiple: false,
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
noDrag: true,
|
||||
onDrop: (acceptedFiles) => {
|
||||
const file = acceptedFiles[0];
|
||||
const replacingItemId = replacingItemIdRef.current;
|
||||
|
||||
if (file && replacingItemId) {
|
||||
void onReplacePdf(replacingItemId, file);
|
||||
replacingItemIdRef.current = null;
|
||||
}
|
||||
},
|
||||
onDropRejected: (fileRejections) => void onFileDropRejected(fileRejections),
|
||||
onFileDialogCancel: () => {
|
||||
replacingItemIdRef.current = null;
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
|
||||
trpc.envelope.item.createMany.useMutation({
|
||||
onSuccess: ({ data }) => {
|
||||
@@ -108,11 +136,29 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const canItemsBeModified = useMemo(
|
||||
() => canEnvelopeItemsBeModified(envelope, envelope.recipients),
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
);
|
||||
|
||||
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
|
||||
onSuccess: ({ data, fields }) => {
|
||||
// Update the envelope item with the new documentDataId.
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === data.id ? { ...item, documentDataId: data.documentDataId } : item,
|
||||
),
|
||||
});
|
||||
|
||||
// When fields were created or deleted during the replacement,
|
||||
// the server returns the full updated field list.
|
||||
if (fields) {
|
||||
setLocalEnvelope({ fields });
|
||||
editorFields.resetForm(fields);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const newUploadingFiles: (LocalFile & {
|
||||
file: File;
|
||||
@@ -125,6 +171,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
title: file.name,
|
||||
file,
|
||||
isUploading: isEmbedded ? false : true,
|
||||
isReplacing: false,
|
||||
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
|
||||
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
|
||||
isError: false,
|
||||
@@ -197,12 +244,77 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
envelopeItemId: item.id,
|
||||
title: item.title,
|
||||
isUploading: false,
|
||||
isReplacing: false,
|
||||
isError: false,
|
||||
})),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const onReplacePdf = async (envelopeItemId: string, file: File) => {
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: true } : f)),
|
||||
);
|
||||
|
||||
try {
|
||||
if (isEmbedded) {
|
||||
// For embedded mode, store the file data locally on the envelope item.
|
||||
// The actual replacement will happen when the embed flow submits.
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const data = new Uint8Array(arrayBuffer.slice(0));
|
||||
|
||||
// Count pages in the new PDF to remove out-of-bounds fields.
|
||||
const { PDF } = await import('@libpdf/core');
|
||||
const pdfDoc = await PDF.load(data);
|
||||
const newPageCount = pdfDoc.getPageCount();
|
||||
|
||||
// Remove fields that are on pages beyond the new PDF's page count.
|
||||
const remainingFields = envelope.fields.filter(
|
||||
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
|
||||
);
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === envelopeItemId ? { ...item, data } : item,
|
||||
),
|
||||
fields: remainingFields,
|
||||
});
|
||||
|
||||
editorFields.resetForm(remainingFields);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode: upload immediately via tRPC.
|
||||
const payload = {
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId,
|
||||
} satisfies TReplaceEnvelopeItemPdfPayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
const replacePromise = replaceEnvelopeItemPdf(formData);
|
||||
registerPendingMutation(replacePromise);
|
||||
|
||||
await replacePromise;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Replace failed`,
|
||||
description: t`Something went wrong while replacing the PDF`,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: false } : f)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide the envelope item from the list on deletion.
|
||||
*/
|
||||
@@ -305,7 +417,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
};
|
||||
|
||||
const dropzoneDisabledMessage = useMemo(() => {
|
||||
if (!canItemsBeModified) {
|
||||
if (!envelopeItemPermissions.canFileBeChanged) {
|
||||
return msg`Cannot upload items after the document has been sent`;
|
||||
}
|
||||
|
||||
@@ -346,7 +458,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -354,6 +466,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||
<input {...getReplaceInputProps()} />
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
@@ -395,7 +508,8 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
key={localFile.id}
|
||||
isDragDisabled={
|
||||
isCreatingEnvelopeItems ||
|
||||
!canItemsBeModified ||
|
||||
!envelopeItemPermissions.canOrderBeChanged ||
|
||||
localFile.isReplacing ||
|
||||
!uploadConfig?.allowConfigureOrder
|
||||
}
|
||||
draggableId={localFile.id}
|
||||
@@ -426,8 +540,9 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
{localFile.envelopeItemId !== null ? (
|
||||
<EnvelopeItemTitleInput
|
||||
disabled={
|
||||
envelope.status !== DocumentStatus.DRAFT ||
|
||||
!uploadConfig?.allowConfigureTitle
|
||||
!envelopeItemPermissions.canTitleBeChanged ||
|
||||
!uploadConfig?.allowConfigureTitle ||
|
||||
localFile.isReplacing
|
||||
}
|
||||
value={localFile.title}
|
||||
dataTestId={`envelope-item-title-input-${localFile.id}`}
|
||||
@@ -445,15 +560,14 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
<Trans>Uploading</Trans>
|
||||
) : localFile.isError ? (
|
||||
<Trans>Something went wrong while uploading this file</Trans>
|
||||
) : // <div className="text-xs text-gray-500">2.4 MB • 3 pages</div>
|
||||
null}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{localFile.isUploading && (
|
||||
<div className="flex h-6 w-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -463,8 +577,28 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!localFile.isUploading &&
|
||||
localFile.envelopeItemId &&
|
||||
{localFile.envelopeItemId &&
|
||||
envelopeItemPermissions.canFileBeChanged &&
|
||||
uploadConfig?.allowReplace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`envelope-item-replace-button-${localFile.id}`}
|
||||
disabled={localFile.isReplacing || localFile.isUploading}
|
||||
onClick={() => {
|
||||
replacingItemIdRef.current = localFile.envelopeItemId;
|
||||
openReplaceFilePicker();
|
||||
}}
|
||||
>
|
||||
{localFile.isReplacing ? (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{localFile.envelopeItemId &&
|
||||
uploadConfig?.allowDelete &&
|
||||
(isEmbedded ? (
|
||||
<Button
|
||||
@@ -472,12 +606,13 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
size="sm"
|
||||
data-testid={`envelope-item-remove-button-${localFile.id}`}
|
||||
onClick={() => onFileDelete(localFile.envelopeItemId!)}
|
||||
disabled={localFile.isReplacing || localFile.isUploading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<EnvelopeItemDeleteDialog
|
||||
canItemBeDeleted={canItemsBeModified}
|
||||
canItemBeDeleted={envelopeItemPermissions.canFileBeChanged}
|
||||
envelopeId={envelope.id}
|
||||
envelopeItemId={localFile.envelopeItemId}
|
||||
envelopeItemTitle={localFile.title}
|
||||
@@ -487,8 +622,9 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`envelope-item-remove-button-${localFile.id}`}
|
||||
disabled={localFile.isReplacing || localFile.isUploading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CopyPlusIcon,
|
||||
DownloadCloudIcon,
|
||||
EyeIcon,
|
||||
FileOutputIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
MousePointerIcon,
|
||||
@@ -35,6 +36,7 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||
|
||||
@@ -101,6 +103,7 @@ export const EnvelopeEditor = () => {
|
||||
allowDistributing,
|
||||
allowDirectLink,
|
||||
allowDuplication,
|
||||
allowSaveAsTemplate,
|
||||
allowDownloadPDF,
|
||||
allowDeletion,
|
||||
},
|
||||
@@ -466,6 +469,28 @@ export const EnvelopeEditor = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowSaveAsTemplate && isDocument && (
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={envelope.id}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Save as Template`)}
|
||||
>
|
||||
<FileOutputIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
<Trans>Save as Template</Trans>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowDownloadPDF && (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
|
||||
@@ -9,6 +9,7 @@ type EnvelopeItemSelectorProps = {
|
||||
secondaryText: React.ReactNode;
|
||||
isSelected: boolean;
|
||||
buttonProps: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
actionSlot?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeItemSelector = ({
|
||||
@@ -17,11 +18,12 @@ export const EnvelopeItemSelector = ({
|
||||
secondaryText,
|
||||
isSelected,
|
||||
buttonProps,
|
||||
actionSlot,
|
||||
}: EnvelopeItemSelectorProps) => {
|
||||
return (
|
||||
<button
|
||||
title={typeof primaryText === 'string' ? primaryText : undefined}
|
||||
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
className={`group flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
isSelected
|
||||
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
|
||||
: 'border-border bg-muted/50 hover:bg-muted/70'
|
||||
@@ -39,11 +41,13 @@ export const EnvelopeItemSelector = ({
|
||||
<div className="truncate text-sm font-medium">{primaryText}</div>
|
||||
<div className="text-xs text-gray-500">{secondaryText}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
|
||||
'bg-green-500': isSelected,
|
||||
})}
|
||||
></div>
|
||||
{actionSlot ?? (
|
||||
<div
|
||||
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
|
||||
'bg-green-500': isSelected,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -52,12 +56,14 @@ type EnvelopeRendererFileSelectorProps = {
|
||||
fields: { envelopeItemId: string }[];
|
||||
className?: string;
|
||||
secondaryOverride?: React.ReactNode;
|
||||
renderItemAction?: (item: { id: string; title: string }) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeRendererFileSelector = ({
|
||||
fields,
|
||||
className,
|
||||
secondaryOverride,
|
||||
renderItemAction,
|
||||
}: EnvelopeRendererFileSelectorProps) => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
@@ -86,6 +92,7 @@ export const EnvelopeRendererFileSelector = ({
|
||||
buttonProps={{
|
||||
onClick: () => setCurrentEnvelopeItem(doc.id),
|
||||
}}
|
||||
actionSlot={renderItemAction?.(doc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,13 @@ import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||
import { sortBy } from 'remeda';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -26,9 +27,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
|
||||
export interface EnvelopeRecipientSelectorProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
selectedRecipient: TEnvelopeRecipientLite | null;
|
||||
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
fields: Field[];
|
||||
align?: 'center' | 'end' | 'start';
|
||||
}
|
||||
@@ -46,7 +47,7 @@ export const EnvelopeRecipientSelector = ({
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
@@ -91,9 +92,9 @@ export const EnvelopeRecipientSelector = ({
|
||||
|
||||
interface EnvelopeRecipientSelectorCommandProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
selectedRecipient: TEnvelopeRecipientLite | null;
|
||||
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
fields: Field[];
|
||||
placeholder?: string;
|
||||
}
|
||||
@@ -109,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const recipientsByRole = useCallback(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
const recipientsByRole: Record<RecipientRole, TEnvelopeRecipientLite[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
@@ -141,7 +142,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
|
||||
[(r) => r.id, 'asc'],
|
||||
),
|
||||
] as [RecipientRole, Recipient[]],
|
||||
] as [RecipientRole, TEnvelopeRecipientLite[]],
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
@@ -156,7 +157,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
@@ -247,7 +248,11 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
);
|
||||
};
|
||||
|
||||
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
|
||||
const extractRecipientLabel = (
|
||||
recipient: TEnvelopeRecipientLite,
|
||||
recipients: TEnvelopeRecipientLite[],
|
||||
i18n: I18n,
|
||||
) => {
|
||||
if (recipient.name && recipient.email) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,7 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import {
|
||||
ErrorCode as DropzoneErrorCode,
|
||||
ErrorCode,
|
||||
type FileRejection,
|
||||
useDropzone,
|
||||
} from 'react-dropzone';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -25,6 +20,7 @@ import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -41,7 +37,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
type,
|
||||
className,
|
||||
}: EnvelopeDropZoneWrapperProps) => {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
const { folderId } = useParams();
|
||||
@@ -167,42 +163,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||
const { file, errors } = fileRejections[0];
|
||||
|
||||
if (!errors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorNodes = errors.map((error, index) => (
|
||||
<span key={index} className="block">
|
||||
{match(error.code)
|
||||
.with(ErrorCode.FileTooLarge, () => (
|
||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
||||
))
|
||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
||||
.with(ErrorCode.TooManyFiles, () => (
|
||||
<Trans>Only one file can be uploaded at a time</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Unknown error</Trans>
|
||||
))}
|
||||
</span>
|
||||
));
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<span className="font-medium">
|
||||
<Trans>{file.name} couldn't be uploaded:</Trans>
|
||||
</span>
|
||||
{errorNodes}
|
||||
</>
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
description,
|
||||
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -11,12 +11,12 @@ import { match } from 'ts-pattern';
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
|
||||
import {
|
||||
@@ -39,7 +39,7 @@ export type EnvelopeUploadButtonProps = {
|
||||
* Upload an envelope
|
||||
*/
|
||||
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
|
||||
@@ -168,7 +168,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -53,6 +53,10 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
const rootPath =
|
||||
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
if (parentId) {
|
||||
return `${rootPath}/folders?parentId=${parentId}`;
|
||||
}
|
||||
|
||||
return `${rootPath}/folders`;
|
||||
};
|
||||
|
||||
@@ -193,14 +197,14 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{foldersData.folders.length > 12 && (
|
||||
{unpinnedFolders.length > 12 && (
|
||||
<div className="mt-2 flex items-center justify-center">
|
||||
<Link
|
||||
prefetch="intent"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
to={formatViewAllFoldersPath()}
|
||||
>
|
||||
View all folders
|
||||
<Trans>View all folders</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentStatus, type Recipient } from '@prisma/client';
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
@@ -15,7 +16,7 @@ import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
position?: 'top' | 'bottom';
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
@@ -74,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Completed</Trans>
|
||||
</h1>
|
||||
{completedRecipients.map((recipient: Recipient) => (
|
||||
{completedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
@@ -98,7 +99,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient: Recipient) => (
|
||||
{rejectedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
@@ -122,7 +123,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Waiting</Trans>
|
||||
</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
{waitingRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
@@ -137,7 +138,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Opened</Trans>
|
||||
</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
{openedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
@@ -152,7 +153,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Uncompleted</Trans>
|
||||
</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
{uncompletedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
getExtraRecipientsType,
|
||||
getRecipientType,
|
||||
} from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
|
||||
const renderStackAvatars = (recipients: Recipient[]) => {
|
||||
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
|
||||
const renderStackAvatars = (recipients: TRecipientLite[]) => {
|
||||
const zIndex = 50;
|
||||
const itemsToRender = recipients.slice(0, 5);
|
||||
const remainingItems = recipients.length - itemsToRender.length;
|
||||
|
||||
return itemsToRender.map((recipient: Recipient, index: number) => {
|
||||
return itemsToRender.map((recipient, index: number) => {
|
||||
const first = index === 0;
|
||||
|
||||
if (index === 4 && remainingItems > 0) {
|
||||
|
||||
@@ -136,6 +136,7 @@ export const TemplateEditForm = ({
|
||||
templateId: template.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
type: data.templateType,
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
|
||||
@@ -1,49 +1,53 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { PenIcon, PlusIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
export type TemplatePageViewRecipientsProps = {
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
envelopeId: string;
|
||||
templateRootPath: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export const TemplatePageViewRecipients = ({
|
||||
recipients,
|
||||
envelopeId,
|
||||
templateRootPath,
|
||||
readOnly = false,
|
||||
}: TemplatePageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<h1 className="font-medium text-foreground">
|
||||
<Trans>Recipients</Trans>
|
||||
</h1>
|
||||
|
||||
<Link
|
||||
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
|
||||
title={_(msg`Modify recipients`)}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
{recipients.length === 0 ? (
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<PenIcon className="ml-2 h-3 w-3" />
|
||||
)}
|
||||
</Link>
|
||||
{!readOnly && (
|
||||
<Link
|
||||
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
|
||||
title={_(msg`Modify recipients`)}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
{recipients.length === 0 ? (
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<PenIcon className="ml-2 h-3 w-3" />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="text-muted-foreground divide-y border-t">
|
||||
<ul className="divide-y border-t text-muted-foreground">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -60,13 +64,13 @@ export const TemplatePageViewRecipients = ({
|
||||
}
|
||||
primaryText={
|
||||
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
|
||||
<p className="text-muted-foreground text-sm">{recipient.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{recipient.name}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-sm text-muted-foreground">{recipient.email}</p>
|
||||
)
|
||||
}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { TemplateType as TemplateTypePrisma } from '@prisma/client';
|
||||
import { Globe2, Lock } from 'lucide-react';
|
||||
import { Building2, Globe2, Lock } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -28,6 +28,11 @@ const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
|
||||
icon: Globe2,
|
||||
color: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
ORGANISATION: {
|
||||
label: msg`Organisation`,
|
||||
icon: Building2,
|
||||
color: 'text-orange-500 dark:text-orange-300',
|
||||
},
|
||||
};
|
||||
|
||||
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
|
||||
@@ -21,17 +21,17 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'dark:bg-background flex flex-col items-center rounded-xl bg-neutral-100 p-4',
|
||||
'flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
|
||||
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-sm lowercase text-muted-foreground">
|
||||
{baseUrl.host}/u/{user.url}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="bg-primary/10 rounded-full p-1.5">
|
||||
<div className="bg-background flex h-20 w-20 items-center justify-center rounded-full border-2">
|
||||
<div className="rounded-full bg-primary/10 p-1.5">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
|
||||
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,16 +41,16 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<h2 className="max-w-[12rem] truncate text-2xl font-semibold">{user.name}</h2>
|
||||
|
||||
<VerifiedIcon className="text-primary h-8 w-8" />
|
||||
<VerifiedIcon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="dark:bg-foreground/30 mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300" />
|
||||
<div className="dark:bg-foreground/20 mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200" />
|
||||
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
|
||||
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full">
|
||||
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
|
||||
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
|
||||
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
|
||||
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
|
||||
<Trans>Documents</Trans>
|
||||
</div>
|
||||
|
||||
@@ -59,14 +59,14 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
||||
className="flex items-center justify-between gap-x-6 bg-background p-4"
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<File className="text-muted-foreground/80 h-8 w-8" strokeWidth={1.5} />
|
||||
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="dark:bg-foreground/30 h-1.5 w-24 rounded-full bg-neutral-300 md:w-36" />
|
||||
<div className="dark:bg-foreground/20 h-1.5 w-16 rounded-full bg-neutral-200 md:w-24" />
|
||||
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
|
||||
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
@@ -46,7 +47,7 @@ const RECIPIENT_ROLE_LABELS: Record<RecipientRole, string> = {
|
||||
|
||||
const ZAdminUpdateRecipientFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
email: zEmail(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@@ -23,7 +24,11 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||
const recipient = findRecipientByEmail({
|
||||
recipients: row.recipients,
|
||||
userEmail: user.email,
|
||||
teamEmail: team.teamEmail?.email,
|
||||
});
|
||||
|
||||
const isOwner = row.user.id === user.id;
|
||||
const isRecipient = !!recipient;
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import {
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
EyeIcon,
|
||||
FileOutputIcon,
|
||||
FolderInput,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
@@ -22,7 +23,10 @@ import { Link } from 'react-router';
|
||||
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 { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { findRecipientByEmail } from '@documenso/lib/utils/recipients';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,13 +36,15 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
|
||||
|
||||
export type DocumentsTableActionDropdownProps = {
|
||||
row: TDocumentRow;
|
||||
@@ -53,11 +59,16 @@ export const DocumentsTableActionDropdown = ({
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||
const recipient = findRecipientByEmail({
|
||||
recipients: row.recipients,
|
||||
userEmail: user.email,
|
||||
teamEmail: team.teamEmail?.email,
|
||||
});
|
||||
|
||||
const isOwner = row.user.id === user.id;
|
||||
// const isRecipient = !!recipient;
|
||||
@@ -68,6 +79,16 @@ export const DocumentsTableActionDropdown = ({
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const { canTitleBeChanged } = getEnvelopeItemPermissions(
|
||||
{
|
||||
completedAt: row.completedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: row.status,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||
|
||||
@@ -121,6 +142,13 @@ export const DocumentsTableActionDropdown = ({
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canManageDocument && canTitleBeChanged && (
|
||||
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Rename</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeStatus={row.status}
|
||||
@@ -135,9 +163,22 @@ export const DocumentsTableActionDropdown = ({
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
<EnvelopeDuplicateDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onMoveDocument && canManageDocument && (
|
||||
@@ -153,10 +194,21 @@ export const DocumentsTableActionDropdown = ({
|
||||
Void
|
||||
</DropdownMenuItem> */}
|
||||
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</DropdownMenuItem>
|
||||
<EnvelopeDeleteDialog
|
||||
id={row.envelopeId}
|
||||
type={EnvelopeType.DOCUMENT}
|
||||
status={row.status}
|
||||
title={row.title}
|
||||
canManageDocument={canManageDocument}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Share</Trans>
|
||||
@@ -192,20 +244,20 @@ export const DocumentsTableActionDropdown = ({
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<DocumentDeleteDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
documentTitle={row.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
canManageDocument={canManageDocument}
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={row.envelopeId}
|
||||
open={isSaveAsTemplateDialogOpen}
|
||||
onOpenChange={setSaveAsTemplateDialogOpen}
|
||||
/>
|
||||
|
||||
<DocumentDuplicateDialog
|
||||
<EnvelopeRenameDialog
|
||||
id={row.envelopeId}
|
||||
token={recipient?.token}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
initialTitle={row.title}
|
||||
open={isRenameDialogOpen}
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
onSuccess={async () => {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,11 @@ 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 { findRecipientByEmail } from '@documenso/lib/utils/recipients';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DataTableTitleProps = {
|
||||
row: TDocumentRow;
|
||||
teamUrl: string;
|
||||
@@ -12,8 +15,13 @@ export type DataTableTitleProps = {
|
||||
|
||||
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
const { user } = useSession();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||
const recipient = findRecipientByEmail({
|
||||
recipients: row.recipients,
|
||||
userEmail: user.email,
|
||||
teamEmail: team.teamEmail?.email,
|
||||
});
|
||||
|
||||
const isOwner = row.user.id === user.id;
|
||||
const isRecipient = !!recipient;
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { findRecipientByEmail } from '@documenso/lib/utils/recipients';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
@@ -91,7 +92,13 @@ export const DocumentsTable = ({
|
||||
},
|
||||
{
|
||||
header: _(msg`Title`),
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||
cell: ({ row }) => (
|
||||
<DataTableTitle
|
||||
row={row.original}
|
||||
teamUrl={team?.url}
|
||||
teamEmail={team?.teamEmail?.email}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sender',
|
||||
@@ -213,12 +220,17 @@ export const DocumentsTable = ({
|
||||
type DataTableTitleProps = {
|
||||
row: DocumentsTableRow;
|
||||
teamUrl: string;
|
||||
teamEmail?: string;
|
||||
};
|
||||
|
||||
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
const DataTableTitle = ({ row, teamUrl, teamEmail }: DataTableTitleProps) => {
|
||||
const { user } = useSession();
|
||||
|
||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||
const recipient = findRecipientByEmail({
|
||||
recipients: row.recipients,
|
||||
userEmail: user.email,
|
||||
teamEmail,
|
||||
});
|
||||
|
||||
const isOwner = row.user.id === user.id;
|
||||
const isRecipient = !!recipient;
|
||||
|
||||
@@ -62,7 +62,14 @@ export const OrganisationInsightsTable = ({
|
||||
{
|
||||
header: _(msg`Team Name`),
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
className="block max-w-full truncate hover:underline"
|
||||
to={`/admin/teams/${row.original.id}`}
|
||||
>
|
||||
{row.getValue('name')}
|
||||
</Link>
|
||||
),
|
||||
size: 240,
|
||||
},
|
||||
{
|
||||
@@ -276,12 +283,12 @@ const SummaryCard = ({
|
||||
value: number;
|
||||
subtitle?: string;
|
||||
}) => (
|
||||
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
|
||||
<Icon className="text-muted-foreground h-4 w-4 items-start" />
|
||||
<div className="flex items-start gap-x-2 rounded-lg border bg-card px-4 py-3">
|
||||
<Icon className="h-4 w-4 items-start text-muted-foreground" />
|
||||
<div className="-mt-0.5 space-y-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
@@ -108,7 +109,7 @@ export const OrganisationMemberInvitesTable = () => {
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={row.original.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">{row.original.email}</span>
|
||||
<span className="font-semibold text-foreground/80">{row.original.email}</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -129,7 +130,7 @@ export const OrganisationMemberInvitesTable = () => {
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
@@ -149,17 +150,22 @@ export const OrganisationMemberInvitesTable = () => {
|
||||
<Trans>Resend</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () =>
|
||||
deleteOrganisationMemberInvitations({
|
||||
organisationId: organisation.id,
|
||||
invitationIds: [row.original.id],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</DropdownMenuItem>
|
||||
{isOrganisationRoleWithinUserHierarchy(
|
||||
organisation.currentOrganisationRole,
|
||||
row.original.organisationRole,
|
||||
) && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () =>
|
||||
deleteOrganisationMemberInvitations({
|
||||
organisationId: organisation.id,
|
||||
invitationIds: [row.original.id],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
|
||||
@@ -74,7 +74,7 @@ export const SettingsPublicProfileTemplatesTable = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dark:divide-foreground/30 dark:border-foreground/30 mt-6 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200">
|
||||
<div className="mt-6 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
|
||||
{/* Loading and error handling states. */}
|
||||
{publicDirectTemplates.length === 0 && (
|
||||
<>
|
||||
@@ -84,10 +84,10 @@ export const SettingsPublicProfileTemplatesTable = () => {
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
||||
className="flex items-center justify-between gap-x-6 bg-background p-4"
|
||||
>
|
||||
<div className="flex gap-x-2">
|
||||
<FileIcon className="text-muted-foreground/40 h-8 w-8" strokeWidth={1.5} />
|
||||
<FileIcon className="h-8 w-8 text-muted-foreground/40" strokeWidth={1.5} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
@@ -95,12 +95,12 @@ export const SettingsPublicProfileTemplatesTable = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoadingError && (
|
||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
||||
<div className="flex h-32 flex-col items-center justify-center text-sm text-muted-foreground">
|
||||
<Trans>Unable to load your public profile templates at this time</Trans>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -114,12 +114,12 @@ export const SettingsPublicProfileTemplatesTable = () => {
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
|
||||
<div className="flex h-32 flex-col items-center justify-center text-sm text-muted-foreground">
|
||||
<Trans>No public profile templates found</Trans>
|
||||
<ManagePublicTemplateDialog
|
||||
directTemplates={privateDirectTemplates}
|
||||
trigger={
|
||||
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
|
||||
<button className="mt-1 text-xs hover:text-muted-foreground/80">
|
||||
<Trans>Click here to get started</Trans>
|
||||
</button>
|
||||
}
|
||||
@@ -133,23 +133,23 @@ export const SettingsPublicProfileTemplatesTable = () => {
|
||||
{publicDirectTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-background flex items-center justify-between gap-x-6 p-4"
|
||||
className="flex items-center justify-between gap-x-6 bg-background p-4"
|
||||
>
|
||||
<div className="flex gap-x-2">
|
||||
<FileIcon
|
||||
className="text-muted-foreground/40 h-8 w-8 flex-shrink-0"
|
||||
className="h-8 w-8 flex-shrink-0 text-muted-foreground/40"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="text-sm break-all">{template.publicTitle}</p>
|
||||
<p className="text-xs text-neutral-400 break-all">{template.publicDescription}</p>
|
||||
<p className="break-all text-sm">{template.publicTitle}</p>
|
||||
<p className="break-all text-xs text-neutral-400">{template.publicDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="center" side="left">
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient, TemplateDirectLink } from '@prisma/client';
|
||||
import { Copy, Edit, FolderIcon, MoreHorizontal, Share2Icon, Trash2, Upload } from 'lucide-react';
|
||||
import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
|
||||
import {
|
||||
Copy,
|
||||
Edit,
|
||||
FolderIcon,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Share2Icon,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -14,10 +24,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
|
||||
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
|
||||
import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
|
||||
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
|
||||
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
|
||||
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
|
||||
|
||||
export type TemplatesTableActionDropdownProps = {
|
||||
@@ -29,7 +40,7 @@ export type TemplatesTableActionDropdownProps = {
|
||||
folderId?: string | null;
|
||||
envelopeId: string;
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
templateRootPath: string;
|
||||
teamId: number;
|
||||
@@ -42,14 +53,13 @@ export const TemplatesTableActionDropdown = ({
|
||||
teamId,
|
||||
onDelete,
|
||||
}: TemplatesTableActionDropdownProps) => {
|
||||
const { user } = useSession();
|
||||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
|
||||
|
||||
const isOwner = row.userId === user.id;
|
||||
const isTeamTemplate = row.teamId === teamId;
|
||||
const canMutate = isTeamTemplate;
|
||||
|
||||
const formatPath = `${templateRootPath}/${row.envelopeId}/edit`;
|
||||
|
||||
@@ -62,74 +72,90 @@ export const TemplatesTableActionDropdown = ({
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||
<DropdownMenuItem disabled={!canMutate} asChild>
|
||||
<Link prefetch="intent" to={formatPath}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDuplicateDialogOpen(true)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
{canMutate && (
|
||||
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Rename</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
directLink={row.directLink}
|
||||
trigger={
|
||||
<div
|
||||
data-testid="template-direct-link"
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Share2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Direct link</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{canMutate && (
|
||||
<EnvelopeDuplicateDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||
{canMutate && (
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
directLink={row.directLink}
|
||||
trigger={
|
||||
<div
|
||||
data-testid="template-direct-link"
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Share2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Direct link</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!canMutate} onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<TemplateBulkSendDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
trigger={
|
||||
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{canMutate && (
|
||||
<TemplateBulkSendDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
trigger={
|
||||
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
{canMutate && (
|
||||
<EnvelopeDeleteDialog
|
||||
id={row.envelopeId}
|
||||
type={EnvelopeType.TEMPLATE}
|
||||
status={DocumentStatus.DRAFT}
|
||||
title={row.title}
|
||||
canManageDocument={canMutate}
|
||||
onDelete={onDelete}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
<TemplateDuplicateDialog
|
||||
id={row.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
<TemplateDeleteDialog
|
||||
id={row.id}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
<TemplateMoveToFolderDialog
|
||||
templateId={row.id}
|
||||
templateTitle={row.title}
|
||||
@@ -137,6 +163,17 @@ export const TemplatesTableActionDropdown = ({
|
||||
onOpenChange={setMoveToFolderDialogOpen}
|
||||
currentFolderId={row.folderId}
|
||||
/>
|
||||
|
||||
<EnvelopeRenameDialog
|
||||
id={row.envelopeId}
|
||||
initialTitle={row.title}
|
||||
open={isRenameDialogOpen}
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
envelopeType="template"
|
||||
onSuccess={async () => {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,15 @@ import { useMemo, useTransition } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Building2Icon,
|
||||
Globe2Icon,
|
||||
InfoIcon,
|
||||
Link2Icon,
|
||||
Loader,
|
||||
LockIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
@@ -166,25 +174,48 @@ export const TemplatesTable = ({
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<Building2Icon className="mr-2 h-5 w-5 text-orange-500 dark:text-orange-300" />
|
||||
<Trans>Organisation</Trans>
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
Organisation templates are shared across all teams within the same
|
||||
organisation. Only the owning team can edit them.
|
||||
</Trans>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-row items-center">
|
||||
<TemplateType type={row.original.type} />
|
||||
cell: ({ row }) => {
|
||||
const isFromOtherTeam = row.original.teamId !== team?.id;
|
||||
|
||||
{row.original.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-2"
|
||||
token={row.original.directLink.token}
|
||||
enabled={row.original.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<TemplateType type={row.original.type} />
|
||||
|
||||
{isFromOtherTeam && row.original.team?.name && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({row.original.team.name})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{row.original.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-2"
|
||||
token={row.original.directLink.token}
|
||||
enabled={row.original.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
|
||||
Reference in New Issue
Block a user