Compare commits

...

6 Commits

Author SHA1 Message Date
Lucas Smith
87aa628dc8 feat: add formdata endpoints for documents,envelopes,templates
Adds the missing endpoints for documents, envelopes and
templates supporting file uploads in a singular request.

Also updates frontend components that would use the prior
hidden endpoints.
2025-11-03 15:07:15 +11:00
Lucas Smith
c85c0cf610 feat: allow multipart requests for public api
Adds support for multipart/form-data requests in the public api
allowing documents to be uploaded without having to perform a secondary
request.

Need to rollout further endpoints for envelopes and templates.

Need to change how we store files to not use `putFileServerSide`
2025-11-02 23:26:43 +11:00
David Nguyen
47bdcd833f chore: extract translations (#2094) 2025-10-24 16:37:10 +11:00
David Nguyen
03eb6af69a feat: polish envelopes (#2090)
## Description

The rest of the owl
2025-10-24 16:22:06 +11:00
Lucas Smith
88836404d1 v1.13.1 2025-10-24 10:50:25 +11:00
Lucas Smith
2eebc0e439 feat: add attachments (#2091) 2025-10-23 23:07:10 +11:00
209 changed files with 9745 additions and 3371 deletions

View File

@@ -27,9 +27,45 @@
font-display: swap; font-display: swap;
} }
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/noto-sans.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Korean noto sans */
@font-face {
font-family: 'Noto Sans Korean';
src: url('/fonts/noto-sans-korean.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Japanese noto sans */
@font-face {
font-family: 'Noto Sans Japanese';
src: url('/fonts/noto-sans-japanese.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Chinese noto sans */
@font-face {
font-family: 'Noto Sans Chinese';
src: url('/fonts/noto-sans-chinese.ttf') format('truetype-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@layer base { @layer base {
:root { :root {
--font-sans: 'Inter'; --font-sans: 'Inter';
--font-signature: 'Caveat'; --font-signature: 'Caveat';
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
} }
} }

View File

@@ -15,6 +15,7 @@ import {
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import * as z from 'zod'; import * as z from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
@@ -61,8 +62,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeDistributeDialogProps = { export type EnvelopeDistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & { envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Pick<Field, 'type' | 'recipientId'>[];
}; };
onDistribute?: () => Promise<void>;
trigger?: React.ReactNode; trigger?: React.ReactNode;
}; };
@@ -84,7 +86,11 @@ export const ZEnvelopeDistributeFormSchema = z.object({
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>; export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => { export const EnvelopeDistributeDialog = ({
envelope,
trigger,
onDistribute,
}: EnvelopeDistributeDialogProps) => {
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const recipients = envelope.recipients; const recipients = envelope.recipients;
@@ -127,22 +133,36 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
const distributionMethod = watch('meta.distributionMethod'); const distributionMethod = watch('meta.distributionMethod');
const everySignerHasSignature = useMemo( const recipientsMissingSignatureFields = useMemo(
() => () =>
envelope.recipients envelope.recipients.filter(
.filter((recipient) => recipient.role === RecipientRole.SIGNER) (recipient) =>
.every((recipient) => recipient.role === RecipientRole.SIGNER &&
envelope.fields.some( !envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
), ),
), ),
[envelope.recipients, envelope.fields], [envelope.recipients, envelope.fields],
); );
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
}
if (envelope.recipients.length === 0) {
return 'MISSING_RECIPIENTS';
}
return null;
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => { const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try { try {
await distributeEnvelope({ envelopeId: envelope.id, meta }); await distributeEnvelope({ envelopeId: envelope.id, meta });
await onDistribute?.();
toast({ toast({
title: t`Envelope distributed`, title: t`Envelope distributed`,
description: t`Your envelope has been distributed successfully.`, description: t`Your envelope has been distributed successfully.`,
@@ -178,7 +198,7 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
<Trans>Recipients will be able to sign the document once sent</Trans> <Trans>Recipients will be able to sign the document once sent</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{everySignerHasSignature ? ( {!invalidEnvelopeCode ? (
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}> <fieldset disabled={isSubmitting}>
@@ -350,6 +370,8 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
</div> </div>
) : ( ) : (
<ul className="text-muted-foreground divide-y"> <ul className="text-muted-foreground divide-y">
{/* Todo: Envelopes - I don't think this section shows up */}
{recipients.length === 0 && ( {recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm"> <li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans> <Trans>No recipients</Trans>
@@ -426,12 +448,24 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
) : ( ) : (
<> <>
<Alert variant="warning"> <Alert variant="warning">
<AlertDescription> {match(invalidEnvelopeCode)
<Trans> .with('MISSING_RECIPIENTS', () => (
Some signers have not been assigned a signature field. Please assign at least 1 <AlertDescription>
signature field to each signer before proceeding. <Trans>You need at least one recipient to send a document</Trans>
</Trans> </AlertDescription>
</AlertDescription> ))
.with('MISSING_SIGNATURES', () => (
<AlertDescription>
<Trans>The following signers are missing signature fields:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li>
))}
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert> </Alert>
<DialogFooter> <DialogFooter>

View File

@@ -0,0 +1,222 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon, FileTextIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
documentData: DocumentData;
};
type EnvelopeDownloadDialogProps = {
envelopeId: string;
envelopeStatus: DocumentStatus;
envelopeItems?: EnvelopeItemToDownload[];
/**
* The recipient token to download the document.
*
* If not provided, it will be assumed that the current user can access the document.
*/
token?: string;
trigger: React.ReactNode;
};
export const EnvelopeDownloadDialog = ({
envelopeId,
envelopeStatus,
envelopeItems: initialEnvelopeItems,
token,
trigger,
}: EnvelopeDownloadDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const [open, setOpen] = useState(false);
const [isDownloadingState, setIsDownloadingState] = useState<{
[envelopeItemIdAndVersion: string]: boolean;
}>({});
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
`${envelopeItemId}-${version}`;
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpc.envelope.item.getManyByToken.useQuery(
{
envelopeId,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
const onDownload = async (
envelopeItem: EnvelopeItemToDownload,
version: 'original' | 'signed',
) => {
const { id: envelopeItemId } = envelopeItem;
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
return;
}
setIsDownloadingState((prev) => ({
...prev,
[generateDownloadKey(envelopeItemId, version)]: true,
}));
try {
const data = await getFile({
type: envelopeItem.documentData.type,
data:
version === 'signed'
? envelopeItem.documentData.data
: envelopeItem.documentData.initialData,
});
const blob = new Blob([data], {
type: 'application/pdf',
});
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
downloadFile({
filename,
data: blob,
});
setIsDownloadingState((prev) => ({
...prev,
[generateDownloadKey(envelopeItemId, version)]: false,
}));
} catch (error) {
setIsDownloadingState((prev) => ({
...prev,
[generateDownloadKey(envelopeItemId, version)]: false,
}));
console.error(error);
toast({
title: t`Something went wrong`,
description: t`This document could not be downloaded at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Download Files</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select the files you would like to download.</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{isLoadingEnvelopeItems ? (
<>
{Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
>
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
<div className="flex w-full flex-col gap-2">
<Skeleton className="h-4 w-28 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
</div>
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
</div>
))}
</>
) : (
envelopeItems.map((item) => (
<div
key={item.id}
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
>
<div className="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>PDF Document</Trans>
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, 'original')}
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
>
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans>Original</Trans>
</Button>
{envelopeStatus === DocumentStatus.COMPLETED && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, 'signed')}
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
>
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans>Signed</Trans>
</Button>
)}
</div>
</div>
))
)}
{/* Todo: Envelopes - Download all button */}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,186 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm, useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { type TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
export type SignFieldCheckboxDialogProps = {
fieldMeta: TCheckboxFieldMeta;
validationRule: '>=' | '=' | '<=';
validationLength: number;
preselectedIndices: number[];
};
export const SignFieldCheckboxDialog = createCallable<
SignFieldCheckboxDialogProps,
number[] | null
>(({ call, fieldMeta, validationRule, validationLength, preselectedIndices }) => {
const ZSignFieldCheckboxFormSchema = z
.object({
values: z.array(
z.object({
checked: z.boolean(),
value: z.string(),
}),
),
})
.superRefine((data, ctx) => {
// Allow unselecting all options if the field is not required even if
// validation is not met.
if (!fieldMeta.required && data.values.every((value) => !value.checked)) {
return;
}
const numberOfSelectedValues = data.values.filter((value) => value.checked).length;
const isValid = validateCheckboxLength(
numberOfSelectedValues,
validationRule,
validationLength,
);
if (!isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Validation failed`.id,
});
}
});
const form = useForm<z.infer<typeof ZSignFieldCheckboxFormSchema>>({
resolver: zodResolver(ZSignFieldCheckboxFormSchema),
defaultValues: {
values: (fieldMeta.values || []).map((value, index) => ({
checked: preselectedIndices.includes(index) || false,
value: value.value,
})),
},
});
const formValues = useWatch({
control: form.control,
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sign Checkbox Field</Trans>
</DialogTitle>
<DialogDescription
className={cn('mt-4', {
'text-destructive': Object.keys(form.formState.errors).length > 0,
})}
>
{match(validationRule)
.with('>=', () => (
<Plural
value={validationLength}
one="Select at least # option"
other="Select at least # options"
/>
))
.with('=', () => (
<Plural
value={validationLength}
one="Select exactly # option"
other="Select exactly # options"
/>
))
.with('<=', () => (
<Plural
value={validationLength}
one="Select at most # option"
other="Select at most # options"
/>
))
.exhaustive()}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) =>
call.end(
data.values
.map((value, i) => (value.checked ? i : null))
.filter((value) => value !== null),
),
)}
>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<ul className="space-y-3">
{(formValues.values || []).map((value, index) => (
<li key={`checkbox-${index}`}>
<FormField
control={form.control}
name={`values.${index}`}
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center">
<Checkbox
id={`checkbox-value-${index}`}
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
checked={field.value.checked}
onCheckedChange={(checked) => {
field.onChange({
...field.value,
checked,
});
}}
/>
<label
className="text-muted-foreground ml-2 w-full text-sm"
htmlFor={`checkbox-value-${index}`}
>
{value.value}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
</li>
))}
</ul>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,40 +1,15 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { useLingui } from '@lingui/react/macro';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call'; import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta'; import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, CommandDialog,
DialogContent, CommandEmpty,
DialogDescription, CommandGroup,
DialogFooter, CommandInput,
DialogHeader, CommandItem,
DialogTitle, CommandList,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/command';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const ZSignFieldDropdownFormSchema = z.object({
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
});
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
export type SignFieldDropdownDialogProps = { export type SignFieldDropdownDialogProps = {
fieldMeta: TDropdownFieldMeta; fieldMeta: TDropdownFieldMeta;
@@ -46,72 +21,25 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
const values = fieldMeta.values?.map((value) => value.value) ?? []; const values = fieldMeta.values?.map((value) => value.value) ?? [];
const form = useForm<TSignFieldDropdownFormSchema>({
resolver: zodResolver(ZSignFieldDropdownFormSchema),
defaultValues: {
dropdown: fieldMeta.defaultValue,
},
});
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <CommandDialog
<DialogContent position="center"> position="start"
<DialogHeader> dialogContentClassName="mt-4"
<DialogTitle> open={true}
<Trans>Sign Dropdown Field</Trans> onOpenChange={(value) => (!value ? call.end(null) : null)}
</DialogTitle> >
<CommandInput placeholder={t`Select an option`} />
<DialogDescription className="mt-4"> <CommandList>
<Trans>Select a value to sign into the field</Trans> <CommandEmpty>No results found.</CommandEmpty>
</DialogDescription> <CommandGroup heading={t`Options`}>
</DialogHeader> {values.map((value, i) => (
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
<Form {...form}> {value}
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}> </CommandItem>
<fieldset ))}
className="flex h-full flex-col space-y-4" </CommandGroup>
disabled={form.formState.isSubmitting} </CommandList>
> </CommandDialog>
<FormField
control={form.control}
name="dropdown"
render={({ field }) => (
<FormItem>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue placeholder={t`Select an option`} />
</SelectTrigger>
<SelectContent>
{values.map((value, i) => (
<SelectItem key={i} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
); );
}, },
); );

View File

@@ -29,20 +29,22 @@ const ZSignFieldEmailFormSchema = z.object({
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>; type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
export type SignFieldEmailDialogProps = Record<string, never>; export type SignFieldEmailDialogProps = {
placeholderEmail: string | null;
};
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>( export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
({ call }) => { ({ call, placeholderEmail }) => {
const form = useForm<TSignFieldEmailFormSchema>({ const form = useForm<TSignFieldEmailFormSchema>({
resolver: zodResolver(ZSignFieldEmailFormSchema), resolver: zodResolver(ZSignFieldEmailFormSchema),
defaultValues: { defaultValues: {
email: '', email: placeholderEmail || '',
}, },
}); });
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Email</Trans> <Trans>Sign Email</Trans>

View File

@@ -45,7 +45,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Initials</Trans> <Trans>Sign Initials</Trans>

View File

@@ -44,7 +44,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Name</Trans> <Trans>Sign Name</Trans>

View File

@@ -30,7 +30,7 @@ import { Input } from '@documenso/ui/primitives/input';
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => { const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
let schema = z.coerce.number({ let schema = z.coerce.number({
invalid_type_error: msg`Please enter a valid number`.id, // Todo: Envelopes - Check that this works invalid_type_error: msg`Please enter a valid number`.id,
}); });
const { numberFormat, minValue, maxValue } = fieldMeta; const { numberFormat, minValue, maxValue } = fieldMeta;
@@ -55,9 +55,7 @@ const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
return foundRegex.test(value.toString()); return foundRegex.test(value.toString());
}, },
{ {
message: `Number needs to be formatted as ${numberFormat}`, message: msg`Number needs to be formatted as ${numberFormat}`.id,
// Todo: Envelopes
// message: msg`Number needs to be formatted as ${numberFormat}`.id,
}, },
); );
} }
@@ -86,7 +84,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Number Field</Trans> <Trans>Sign Number Field</Trans>

View File

@@ -50,7 +50,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
return ( return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>Sign Text Field</Trans> <Trans>Sign Text Field</Trans>

View File

@@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -54,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true); setIsUploadingFile(true);
try { try {
const response = await putPdfFile(file); const payload = {
const { legacyTemplateId: id } = await createTemplate({
title: file.name, title: file.name,
templateDocumentDataId: response.id,
folderId: folderId, folderId: folderId,
}); } satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({ toast({
title: _(msg`Template document uploaded`), title: _(msg`Template document uploaded`),

View File

@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client'; import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client'; import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { InfoIcon, Plus, Upload, X } from 'lucide-react'; import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import * as z from 'zod'; import * as z from 'zod';
@@ -16,6 +16,10 @@ import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template'; } from '@documenso/lib/constants/template';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -41,6 +45,7 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast'; import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -49,8 +54,13 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(), distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false), useCustomDocument: z.boolean().default(false),
customDocumentData: z customDocumentData: z
.any() .array(
.refine((data) => data instanceof File || data === undefined) z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
)
.optional(), .optional(),
recipients: z.array( recipients: z.array(
z.object({ z.object({
@@ -65,6 +75,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>; type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type TemplateUseDialogProps = { export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number; templateId: number;
templateSigningOrder?: DocumentSigningOrder | null; templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[]; recipients: Recipient[];
@@ -77,6 +88,7 @@ export function TemplateUseDialog({
recipients, recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL, documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath, documentRootPath,
envelopeId,
templateId, templateId,
templateSigningOrder, templateSigningOrder,
trigger, trigger,
@@ -93,7 +105,7 @@ export function TemplateUseDialog({
defaultValues: { defaultValues: {
distributeDocument: false, distributeDocument: false,
useCustomDocument: false, useCustomDocument: false,
customDocumentData: undefined, customDocumentData: [],
recipients: recipients recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0)) .sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => { .map((recipient) => {
@@ -115,23 +127,50 @@ export function TemplateUseDialog({
}, },
}); });
const { replace, fields: localCustomDocumentData } = useFieldArray({
control: form.control,
name: 'customDocumentData',
});
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const envelopeItems = response?.envelopeItems ?? [];
const { mutateAsync: createDocumentFromTemplate } = const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation(); trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try { try {
let customDocumentDataId: string | undefined = undefined; const customFilesToUpload = (data.customDocumentData || []).filter(
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
);
if (data.useCustomDocument && data.customDocumentData) { const customDocumentData = await Promise.all(
const customDocumentData = await putPdfFile(data.customDocumentData); customFilesToUpload.map(async (item) => {
customDocumentDataId = customDocumentData.id; const customDocumentData = await putPdfFile(item.data);
}
const { id } = await createDocumentFromTemplate({ return {
documentDataId: customDocumentData.id,
envelopeItemId: item.envelopeItemId,
};
}),
);
const { envelopeId } = await createDocumentFromTemplate({
templateId, templateId,
recipients: data.recipients, recipients: data.recipients,
distributeDocument: data.distributeDocument, distributeDocument: data.distributeDocument,
customDocumentDataId, customDocumentData,
}); });
toast({ toast({
@@ -140,7 +179,7 @@ export function TemplateUseDialog({
duration: 5000, duration: 5000,
}); });
let documentPath = `${documentRootPath}/${id}`; let documentPath = `${documentRootPath}/${envelopeId}`;
if ( if (
data.distributeDocument && data.distributeDocument &&
@@ -180,6 +219,18 @@ export function TemplateUseDialog({
} }
}, [open, form]); }, [open, form]);
useEffect(() => {
if (envelopeItems.length > 0 && localCustomDocumentData.length === 0) {
replace(
envelopeItems.map((item) => ({
title: item.title,
data: undefined,
envelopeItemId: item.id,
})),
);
}
}, [envelopeItems, form, open]);
return ( return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}> <Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -384,7 +435,6 @@ export function TemplateUseDialog({
className="text-muted-foreground ml-2 flex items-center text-sm" className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="useCustomDocument" htmlFor="useCustomDocument"
> >
{/* Todo: Envelopes - How will this work? */}
<Trans>Upload custom document</Trans> <Trans>Upload custom document</Trans>
<Tooltip> <Tooltip>
<TooltipTrigger type="button"> <TooltipTrigger type="button">
@@ -406,116 +456,133 @@ export function TemplateUseDialog({
/> />
{form.watch('useCustomDocument') && ( {form.watch('useCustomDocument') && (
<div className="my-4"> <div className="my-4 space-y-2">
<FormField {isLoadingEnvelopeItems ? (
control={form.control} <SpinnerBox className="py-16" />
name="customDocumentData" ) : (
render={({ field }) => ( localCustomDocumentData.map((item, i) => (
<FormItem> <FormField
<FormControl> key={item.id}
<div className="w-full space-y-4"> control={form.control}
<label name={`customDocumentData.${i}.data`}
className={cn( render={({ field }) => (
'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors', <FormItem>
{ <FormControl>
'border-destructive hover:border-destructive': <div
form.formState.errors.customDocumentData, key={item.id}
}, className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
)} >
> <div className="flex-shrink-0">
<div className="text-center"> <div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
{!field.value && ( <FileTextIcon className="text-primary h-5 w-5" />
<>
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
<div className="mt-4 flex text-sm leading-6">
<span className="text-muted-foreground relative">
<Trans>
<span className="text-primary font-semibold">
Click to upload
</span>{' '}
or drag and drop
</Trans>
</span>
</div>
<p className="text-muted-foreground/80 text-xs">
PDF files only
</p>
</>
)}
{field.value && (
<div className="text-muted-foreground space-y-1">
<p className="text-sm font-medium">{field.value.name}</p>
<p className="text-muted-foreground/60 text-xs">
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div> </div>
)}
</div>
<input
type="file"
data-testid="template-use-dialog-file-input"
className="absolute h-full w-full opacity-0"
accept=".pdf,application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
field.onChange(undefined);
return;
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
return;
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
),
});
return;
}
field.onChange(file);
}}
/>
{field.value && (
<div className="absolute right-2 top-2">
<Button
type="button"
variant="destructive"
className="h-6 w-6 p-0"
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
}}
>
<X className="h-4 w-4" />
<div className="sr-only">
<Trans>Clear file</Trans>
</div>
</Button>
</div> </div>
)}
</label> <div className="min-w-0 flex-1">
</div> <h4 className="text-foreground truncate text-sm font-medium">
</FormControl> {item.title}
<FormMessage /> </h4>
</FormItem> <p className="text-muted-foreground mt-0.5 text-xs">
)} {field.value ? (
/> <div>
<Trans>
Custom {(field.value.size / (1024 * 1024)).toFixed(2)}{' '}
MB file
</Trans>
</div>
) : (
<Trans>Default file</Trans>
)}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{field.value ? (
<div className="">
<Button
type="button"
variant="destructive"
size="sm"
className="text-xs"
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
}}
>
<X className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => {
const fileInput = document.getElementById(
`template-use-dialog-file-input-${item.envelopeItemId}`,
);
if (fileInput instanceof HTMLInputElement) {
fileInput.click();
}
}}
>
<UploadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Upload</Trans>
</Button>
)}
<input
type="file"
id={`template-use-dialog-file-input-${item.envelopeItemId}`}
className="hidden"
accept=".pdf,application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
field.onChange(undefined);
return;
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
return;
}
if (
file.size >
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
),
});
return;
}
field.onChange(file);
}}
/>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -37,6 +37,7 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
import { injectCss } from '~/utils/css-vars'; import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form'; import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { EmbedClientLoading } from './embed-client-loading'; import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentCompleted } from './embed-document-completed';
@@ -44,6 +45,7 @@ import { EmbedDocumentFields } from './embed-document-fields';
export type EmbedDirectTemplateClientPageProps = { export type EmbedDirectTemplateClientPageProps = {
token: string; token: string;
envelopeId: string;
updatedAt: Date; updatedAt: Date;
documentData: DocumentData; documentData: DocumentData;
recipient: Recipient; recipient: Recipient;
@@ -55,9 +57,10 @@ export type EmbedDirectTemplateClientPageProps = {
export const EmbedDirectTemplateClientPage = ({ export const EmbedDirectTemplateClientPage = ({
token, token,
envelopeId,
updatedAt, updatedAt,
documentData, documentData,
recipient: _recipient, recipient,
fields, fields,
metadata, metadata,
hidePoweredBy = false, hidePoweredBy = false,
@@ -321,9 +324,13 @@ export const EmbedDirectTemplateClientPage = ({
} }
return ( return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
</div>
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="flex-1"> <div className="flex-1">

View File

@@ -37,6 +37,7 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { injectCss } from '~/utils/css-vars'; import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog'; import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
@@ -48,6 +49,7 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = { export type EmbedSignDocumentClientPageProps = {
token: string; token: string;
documentId: number; documentId: number;
envelopeId: string;
documentData: DocumentData; documentData: DocumentData;
recipient: RecipientWithFields; recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
@@ -62,6 +64,7 @@ export type EmbedSignDocumentClientPageProps = {
export const EmbedSignDocumentClientPage = ({ export const EmbedSignDocumentClientPage = ({
token, token,
documentId, documentId,
envelopeId,
documentData, documentData,
recipient, recipient,
fields, fields,
@@ -274,15 +277,17 @@ export const EmbedSignDocumentClientPage = ({
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
{allowDocumentRejection && ( <div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between"> <DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
{allowDocumentRejection && (
<DocumentSigningRejectDialog <DocumentSigningRejectDialog
documentId={documentId} documentId={documentId}
token={token} token={token}
onRejected={onDocumentRejected} onRejected={onDocumentRejected}
/> />
</div> )}
)} </div>
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}

View File

@@ -1,31 +0,0 @@
// export const numberFormatValues = [
// {
// label: '123,456,789.00',
// value: '123,456,789.00',
// },
// {
// label: '123.456.789,00',
// value: '123.456.789,00',
// },
// {
// label: '123456,789.00',
// value: '123456,789.00',
// },
// ];
export const checkboxValidationRules = ['Select at least', 'Select exactly', 'Select at most'];
export const checkboxValidationLength = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const checkboxValidationSigns = [
{
label: 'Select at least',
value: '>=',
},
{
label: 'Select exactly',
value: '=',
},
{
label: 'Select at most',
value: '<=',
},
];

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
@@ -7,11 +7,19 @@ import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { import {
type TCheckboxFieldMeta as CheckboxFieldMeta, type TCheckboxFieldMeta as CheckboxFieldMeta,
DEFAULT_FIELD_FONT_SIZE,
ZCheckboxFieldMeta, ZCheckboxFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
checkboxValidationLength,
checkboxValidationRules,
checkboxValidationSigns,
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { import {
Form, Form,
FormControl, FormControl,
@@ -30,8 +38,8 @@ import {
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { checkboxValidationLength, checkboxValidationRules } from './constants';
import { import {
EditorGenericFontSizeField,
EditorGenericReadOnlyField, EditorGenericReadOnlyField,
EditorGenericRequiredField, EditorGenericRequiredField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
@@ -44,6 +52,7 @@ const ZCheckboxFieldFormSchema = ZCheckboxFieldMeta.pick({
required: true, required: true,
values: true, values: true,
readOnly: true, readOnly: true,
fontSize: true,
}) })
.extend({ .extend({
validationLength: z.coerce.number().optional(), validationLength: z.coerce.number().optional(),
@@ -90,6 +99,7 @@ export const EditorFieldCheckboxForm = ({
values: value.values || [{ id: 1, checked: false, value: '' }], values: value.values || [{ id: 1, checked: false, value: '' }],
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
}, },
}); });
@@ -99,13 +109,17 @@ export const EditorFieldCheckboxForm = ({
control, control,
}); });
const addValue = () => { const addValue = (numberOfValues: number = 1) => {
const currentValues = form.getValues('values') || []; const currentValues = form.getValues('values') || [];
const newId = const currentMaxId = Math.max(...currentValues.map((val) => val.id));
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
const newValues = [...currentValues, { id: newId, checked: false, value: '' }]; const newValues = Array.from({ length: numberOfValues }, (_, index) => ({
form.setValue('values', newValues); id: currentMaxId + index + 1,
checked: false,
value: '',
}));
form.setValue('values', [...currentValues, ...newValues]);
}; };
const removeValue = (index: number) => { const removeValue = (index: number) => {
@@ -132,10 +146,34 @@ export const EditorFieldCheckboxForm = ({
} }
}, [formValues]); }, [formValues]);
const isValidationRuleMetForPreselectedValues = useMemo(() => {
const preselectedValues = (formValues.values || [])?.filter((value) => value.checked);
if (formValues.validationLength && formValues.validationRule && preselectedValues.length > 0) {
const validationRule = checkboxValidationSigns.find(
(sign) => sign.label === formValues.validationRule,
);
if (!validationRule) {
return false;
}
return validateCheckboxLength(
preselectedValues.length,
validationRule.value,
formValues.validationLength,
);
}
return true;
}, [formValues]);
return ( return (
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
<FormField <FormField
control={form.control} control={form.control}
name="direction" name="direction"
@@ -202,7 +240,25 @@ export const EditorFieldCheckboxForm = ({
<FormControl> <FormControl>
<Select <Select
value={field.value ? String(field.value) : ''} value={field.value ? String(field.value) : ''}
onValueChange={field.onChange} onValueChange={(value) => {
const validationNumber = Number(value);
const currentValues = formValues.values || [];
const minimumNumberOfValuesRequired =
validationNumber - currentValues.length;
if (!formValues.validationRule) {
form.setValue('validationRule', checkboxValidationRules[0]);
}
if (minimumNumberOfValuesRequired > 0) {
addValue(minimumNumberOfValuesRequired);
}
field.onChange(validationNumber);
void form.trigger();
}}
> >
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full"> <SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
<SelectValue placeholder={t`Pick a number`} /> <SelectValue placeholder={t`Pick a number`} />
@@ -239,7 +295,7 @@ export const EditorFieldCheckboxForm = ({
<Trans>Checkbox values</Trans> <Trans>Checkbox values</Trans>
</p> </p>
<button type="button" onClick={addValue}> <button type="button" onClick={() => addValue()}>
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
</div> </div>
@@ -285,6 +341,16 @@ export const EditorFieldCheckboxForm = ({
</li> </li>
))} ))}
</ul> </ul>
{!isValidationRuleMetForPreselectedValues && (
<Alert variant="warning">
<AlertDescription>
<Trans>
The preselected values will be ignored unless they meet the validation criteria.
</Trans>
</AlertDescription>
</Alert>
)}
</section> </section>
</fieldset> </fieldset>
</form> </form>

View File

@@ -8,7 +8,10 @@ import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { type TDropdownFieldMeta as DropdownFieldMeta } from '@documenso/lib/types/field-meta'; import {
DEFAULT_FIELD_FONT_SIZE,
type TDropdownFieldMeta as DropdownFieldMeta,
} from '@documenso/lib/types/field-meta';
import { import {
Form, Form,
FormControl, FormControl,
@@ -28,56 +31,50 @@ import {
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { import {
EditorGenericFontSizeField,
EditorGenericReadOnlyField, EditorGenericReadOnlyField,
EditorGenericRequiredField, EditorGenericRequiredField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
const ZDropdownFieldFormSchema = z const ZDropdownFieldFormSchema = z.object({
.object({ defaultValue: z.string().optional(),
defaultValue: z.string().optional(), values: z
values: z .object({
.object({ value: z.string().min(1, {
value: z.string().min(1, { message: msg`Option value cannot be empty`.id,
message: msg`Option value cannot be empty`.id, }),
}), })
}) .array()
.array() .min(1, {
.min(1, { message: msg`Dropdown must have at least one option`.id,
message: msg`Dropdown must have at least one option`.id, })
}) .superRefine((values, ctx) => {
.refine( const seen = new Map<string, number[]>(); // value → indices
(data) => {
// Todo: Envelopes - This doesn't work.
console.log({
data,
});
if (data) { values.forEach((item, index) => {
const values = data.map((item) => item.value); const key = item.value;
return new Set(values).size === values.length; if (!seen.has(key)) {
seen.set(key, []);
}
seen.get(key)!.push(index);
});
for (const [key, indices] of seen) {
if (indices.length > 1 && key.trim() !== '') {
for (const i of indices) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Duplicate values are not allowed`.id,
path: [i, 'value'],
});
} }
return true; }
},
{
message: 'Duplicate values are not allowed',
},
),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// Default value must be one of the available options
if (data.defaultValue && data.values) {
return data.values.some((item) => item.value === data.defaultValue);
} }
return true; }),
}, required: z.boolean().optional(),
{ readOnly: z.boolean().optional(),
message: 'Default value must be one of the available options', fontSize: z.number().optional(),
path: ['defaultValue'], });
},
);
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>; type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
@@ -102,6 +99,7 @@ export const EditorFieldDropdownForm = ({
values: value.values || [{ value: 'Option 1' }], values: value.values || [{ value: 'Option 1' }],
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
}, },
}); });
@@ -111,7 +109,20 @@ export const EditorFieldDropdownForm = ({
const addValue = () => { const addValue = () => {
const currentValues = form.getValues('values') || []; const currentValues = form.getValues('values') || [];
const newValues = [...currentValues, { value: 'New option' }];
let newValue = 'New option';
// Iterate to create a unique value
for (let i = 0; i < currentValues.length; i++) {
newValue = `New option ${i + 1}`;
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
newValue = `New option ${i + 1}`;
} else {
break;
}
}
const newValues = [...currentValues, { value: newValue }];
form.setValue('values', newValues); form.setValue('values', newValues);
}; };
@@ -127,6 +138,10 @@ export const EditorFieldDropdownForm = ({
newValues.splice(index, 1); newValues.splice(index, 1);
form.setValue('values', newValues); form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
form.setValue('defaultValue', undefined);
}
}; };
useEffect(() => { useEffect(() => {
@@ -140,19 +155,13 @@ export const EditorFieldDropdownForm = ({
} }
}, [formValues]); }, [formValues]);
const { formState } = form;
useEffect(() => {
console.log({
errors: formState.errors,
formValues,
});
}, [formState, formState.errors, formValues]);
return ( return (
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
{/* Todo: Envelopes This is buggy. */}
<FormField <FormField
control={form.control} control={form.control}
name="defaultValue" name="defaultValue"
@@ -163,20 +172,25 @@ export const EditorFieldDropdownForm = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Select
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
{...field} {...field}
value={field.value} value={field.value ?? '-1'}
onValueChange={(val) => field.onChange(val)} onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
> >
<SelectTrigger className="text-muted-foreground bg-background w-full"> <SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Default Value`} /> <SelectValue placeholder={t`Default Value`} />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
{(formValues.values || []).map((item, index) => ( {(formValues.values || [])
<SelectItem key={index} value={item.value || ''}> .filter((item) => item.value)
{item.value} .map((item, index) => (
</SelectItem> <SelectItem key={index} value={item.value || ''}>
))} {item.value}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>Default Value</Trans>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>

View File

@@ -130,6 +130,12 @@ export const EditorFieldNumberForm = ({
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div>
<EditorGenericLabelField formControl={form.control} /> <EditorGenericLabelField formControl={form.control} />
<FormField <FormField
@@ -198,12 +204,6 @@ export const EditorFieldNumberForm = ({
)} )}
/> />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div>
<div className="mt-1"> <div className="mt-1">
<EditorGenericRequiredField formControl={form.control} /> <EditorGenericRequiredField formControl={form.control} />
</div> </div>

View File

@@ -1,47 +1,62 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, Trash } from 'lucide-react'; import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import type { z } from 'zod';
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta'; import {
DEFAULT_FIELD_FONT_SIZE,
type TRadioFieldMeta as RadioFieldMeta,
ZRadioFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form'; import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { import {
EditorGenericFontSizeField,
EditorGenericReadOnlyField, EditorGenericReadOnlyField,
EditorGenericRequiredField, EditorGenericRequiredField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
const ZRadioFieldFormSchema = z const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
.object({ label: true,
label: z.string().optional(), direction: true,
values: z values: true,
.object({ id: z.number(), checked: z.boolean(), value: z.string() }) required: true,
.array() readOnly: true,
.min(1) fontSize: true,
.optional(), }).refine(
required: z.boolean().optional(), (data) => {
readOnly: z.boolean().optional(), // There cannot be more than one checked option
}) if (data.values) {
.refine( const checkedValues = data.values.filter((option) => option.checked);
(data) => { return checkedValues.length <= 1;
// There cannot be more than one checked option }
if (data.values) { return true;
const checkedValues = data.values.filter((option) => option.checked); },
return checkedValues.length <= 1; {
} message: 'There cannot be more than one checked option',
return true; path: ['values'],
}, },
{ );
message: 'There cannot be more than one checked option',
path: ['values'],
},
);
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>; type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
@@ -53,9 +68,12 @@ export type EditorFieldRadioFormProps = {
export const EditorFieldRadioForm = ({ export const EditorFieldRadioForm = ({
value = { value = {
type: 'radio', type: 'radio',
direction: 'vertical',
}, },
onValueChange, onValueChange,
}: EditorFieldRadioFormProps) => { }: EditorFieldRadioFormProps) => {
const { t } = useLingui();
const form = useForm<TRadioFieldFormSchema>({ const form = useForm<TRadioFieldFormSchema>({
resolver: zodResolver(ZRadioFieldFormSchema), resolver: zodResolver(ZRadioFieldFormSchema),
mode: 'onChange', mode: 'onChange',
@@ -64,6 +82,8 @@ export const EditorFieldRadioForm = ({
values: value.values || [{ id: 1, checked: false, value: 'Default value' }], values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
direction: value.direction || 'vertical',
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
}, },
}); });
@@ -107,7 +127,37 @@ export const EditorFieldRadioForm = ({
return ( return (
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2 pb-2"> <fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
<FormField
control={form.control}
name="direction"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Direction</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="vertical">
<Trans>Vertical</Trans>
</SelectItem>
<SelectItem value="horizontal">
<Trans>Horizontal</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<EditorGenericRequiredField formControl={form.control} /> <EditorGenericRequiredField formControl={form.control} />
<EditorGenericReadOnlyField formControl={form.control} /> <EditorGenericReadOnlyField formControl={form.control} />

View File

@@ -0,0 +1,68 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TSignatureFieldMeta,
ZSignatureFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
const ZSignatureFieldFormSchema = ZSignatureFieldMeta.pick({
fontSize: true,
});
type TSignatureFieldFormSchema = z.infer<typeof ZSignatureFieldFormSchema>;
type EditorFieldSignatureFormProps = {
value: TSignatureFieldMeta | undefined;
onValueChange: (value: TSignatureFieldMeta) => void;
};
export const EditorFieldSignatureForm = ({
value = {
type: 'signature',
},
onValueChange,
}: EditorFieldSignatureFormProps) => {
const form = useForm<TSignatureFieldFormSchema>({
resolver: zodResolver(ZSignatureFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
},
});
const { control } = form;
const formValues = useWatch({
control,
});
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
useEffect(() => {
const validatedFormValues = ZSignatureFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'signature',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
</fieldset>
</form>
</Form>
);
};

View File

@@ -5,7 +5,10 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta'; import {
DEFAULT_FIELD_FONT_SIZE,
type TTextFieldMeta as TextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { import {
Form, Form,
FormControl, FormControl,
@@ -69,7 +72,7 @@ export const EditorFieldTextForm = ({
placeholder: value.placeholder || '', placeholder: value.placeholder || '',
text: value.text || '', text: value.text || '',
characterLimit: value.characterLimit || 0, characterLimit: value.characterLimit || 0,
fontSize: value.fontSize || 14, fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left', textAlign: value.textAlign || 'left',
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
@@ -98,6 +101,12 @@ export const EditorFieldTextForm = ({
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div>
<FormField <FormField
control={form.control} control={form.control}
name="label" name="label"
@@ -173,12 +182,6 @@ export const EditorFieldTextForm = ({
)} )}
/> />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div>
<div className="mt-1"> <div className="mt-1">
<EditorGenericRequiredField formControl={form.control} /> <EditorGenericRequiredField formControl={form.control} />
</div> </div>

View File

@@ -39,6 +39,7 @@ export const SubscriptionClaimForm = ({
name: subscriptionClaim.name, name: subscriptionClaim.name,
teamCount: subscriptionClaim.teamCount, teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount, memberCount: subscriptionClaim.memberCount,
envelopeItemCount: subscriptionClaim.envelopeItemCount,
flags: subscriptionClaim.flags, flags: subscriptionClaim.flags,
}, },
}); });
@@ -111,6 +112,30 @@ export const SubscriptionClaimForm = ({
)} )}
/> />
<FormField
control={form.control}
name="envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div> <div>
<FormLabel> <FormLabel>
<Trans>Feature Flags</Trans> <Trans>Feature Flags</Trans>

View File

@@ -0,0 +1,17 @@
import type { SVGAttributes } from 'react';
export type LogoProps = SVGAttributes<SVGSVGElement>;
export const BrandingLogoIcon = ({ ...props }: LogoProps) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 84" {...props}>
<g fill="currentColor">
<path d="M35.53 12.152c-.968.879-2.038 1.91-3.261 3.118a4.55 4.55 0 0 1-2.722.97l-4.098.079 1.194-1.194C33.883 7.885 37.502 4.265 42 4.265s8.118 3.62 15.357 10.86l1.192 1.192-3.957-.075a4.55 4.55 0 0 1-3.004-1.209l-2.373-2.194a69 69 0 0 0-.66-.61l-.128-.119h-.002a35 35 0 0 0-2.244-1.892C44.17 8.684 43 8.338 42 8.338s-2.17.346-4.18 1.88a35 35 0 0 0-2.275 1.92zM71.77 35.444a69 69 0 0 0-.608-.658l-2.196-2.374a4.55 4.55 0 0 1-1.208-3.002l-.077-3.961 1.194 1.194c7.24 7.24 10.86 10.859 10.86 15.357s-3.62 8.118-10.86 15.357l-1.194 1.194.077-3.961a4.55 4.55 0 0 1 1.209-3.002l2.195-2.373q.315-.338.609-.66l.119-.128v-.002a35 35 0 0 0 1.892-2.244c1.534-2.01 1.88-3.18 1.88-4.181s-.346-2.17-1.88-4.18a35 35 0 0 0-1.892-2.245v-.002zM48.51 71.813q.362-.33.747-.69l2.331-2.157a4.55 4.55 0 0 1 3.003-1.208l3.959-.076-1.193 1.193c-7.24 7.24-10.859 10.86-15.357 10.86s-8.118-3.62-15.357-10.86l-1.194-1.194 3.97.076a4.55 4.55 0 0 1 2.991 1.2l1.601 1.47c1.461 1.4 2.69 2.502 3.808 3.355 2.01 1.534 3.18 1.88 4.181 1.88s2.17-.346 4.18-1.88a35 35 0 0 0 2.275-1.92zM12.156 48.476q.364.4.763.825l2.115 2.287a4.55 4.55 0 0 1 1.209 3.002l.076 3.961-1.194-1.194C7.885 50.117 4.265 46.498 4.265 42s3.62-8.118 10.86-15.357l1.193-1.193-.075 3.959a4.55 4.55 0 0 1-1.21 3.004l-2.18 2.357q-.325.346-.626.676l-.117.127v.002a35 35 0 0 0-1.892 2.244C8.684 39.83 8.338 41 8.338 42s.346 2.17 1.88 4.18a35 35 0 0 0 1.92 2.275z" />
<path d="m12.138 35.543 2.896-3.13a4.55 4.55 0 0 0 1.186-2.626c.012-1.61.038-3.013.096-4.254l.003-.17.006-.005c.053-1.072.131-2.021.246-2.875.337-2.506.92-3.578 1.627-4.286s1.78-1.29 4.285-1.626c.87-.117 1.838-.196 2.935-.25l.002-.002h.06c1.285-.062 2.746-.089 4.43-.1a4.55 4.55 0 0 0 2.711-1.257l2.923-2.825h-1.688c-10.238 0-15.357 0-18.538 3.18-3.18 3.181-3.18 8.3-3.18 18.539zM12.138 48.456v1.688c0 10.239 0 15.358 3.18 18.538s8.3 3.18 18.538 3.18h16.289c10.238 0 15.357 0 18.538-3.18 3.18-3.18 3.18-8.3 3.18-18.537v-1.69l-2.897 3.133a4.55 4.55 0 0 0-1.185 2.618c-.012 1.645-.039 3.075-.1 4.335v.04h-.001a35 35 0 0 1-.25 2.936c-.337 2.506-.92 3.578-1.627 4.286s-1.78 1.29-4.285 1.626c-.855.115-1.804.194-2.876.247l-.005.005-.149.003c-1.246.058-2.658.085-4.277.097-.976.1-1.897.515-2.623 1.185l-3.132 2.897H35.573l-3.163-2.906a4.55 4.55 0 0 0-2.61-1.176 110 110 0 0 1-4.324-.1h-.056l-.002-.002a35 35 0 0 1-2.935-.25c-2.505-.336-3.578-.919-4.285-1.626-.708-.708-1.29-1.78-1.627-4.286a35 35 0 0 1-.25-2.935l-.002-.002-.001-.075c-.06-1.251-.086-2.668-.098-4.296a4.55 4.55 0 0 0-1.186-2.621zM67.781 29.794a4.55 4.55 0 0 0 1.185 2.618l2.897 3.132v-1.688c0-10.239 0-15.358-3.18-18.538s-8.3-3.18-18.538-3.18h-1.689l3.132 2.895a4.55 4.55 0 0 0 2.627 1.186c1.6.012 2.997.038 4.232.096l.247.004.008.008a34 34 0 0 1 2.816.244c2.505.337 3.578.919 4.285 1.626.708.708 1.29 1.78 1.627 4.286.117.87.196 1.839.25 2.936l.001.04c.061 1.26.088 2.69.1 4.335M38.91 23.96l-2.747 2.33a2.9 2.9 0 0 1-1.747.689l-4.597.214 2.397-2.397c4.627-4.627 6.94-6.94 9.815-6.94s5.188 2.313 9.815 6.94l2.383 2.382-4.662-.202a2.9 2.9 0 0 1-1.773-.703l-2.074-1.789c-.728-.685-1.345-1.226-1.908-1.656-1.154-.88-1.592-.9-1.78-.9-.19 0-.627.02-1.781.9l-.055.042h-.003l-.027.023c-.387.3-.8.652-1.257 1.067" />
<path d="M61.023 39.995c-.785-.992-1.911-2.163-3.542-3.803a2.9 2.9 0 0 1-.44-1.426l-.202-4.977 2.369 2.368c4.627 4.627 6.94 6.94 6.94 9.815s-2.313 5.188-6.94 9.815l-2.382 2.381.23-4.757a2.9 2.9 0 0 1 .727-1.787l1.742-1.968a28 28 0 0 0 1.387-1.569l.215-.242v-.03l.049-.062c.88-1.154.9-1.592.9-1.781 0-.19-.02-.627-.9-1.78l-.049-.064v-.024zM22.946 40.124l3.175-3.454c.45-.489.719-1.117.762-1.78l.175-2.71c.027-.86.071-1.584.144-2.216l.012-.192.013-.013.009-.065c.193-1.438.488-1.762.622-1.896s.457-.429 1.896-.622c.461-.062.974-.106 1.555-.138l3.9-.385a2.9 2.9 0 0 0 1.678-.75l3.296-3.017h-3.357c-6.543 0-9.815 0-11.847 2.033-1.732 1.732-1.988 4.363-2.026 9.15q-.009 1.246-.007 2.698v3.356" />
<path d="M22.946 43.82v3.357c0 .97 0 1.866.006 2.698.038 4.787.295 7.418 2.027 9.15 1.731 1.732 4.362 1.988 9.15 2.026q1.246.009 2.697.007h10.411q1.45.002 2.697-.007c4.788-.038 7.419-.294 9.15-2.026 2.033-2.033 2.033-5.304 2.033-11.848V43.81l-3.384 3.67a2.9 2.9 0 0 0-.69 1.29c-.006 2.38-.038 4.033-.193 5.306l-.002.068-.008.008-.012.098c-.194 1.438-.489 1.762-.623 1.896-.133.133-.457.429-1.895.622l-.099.013-.008.008-.114.007c-.724.086-1.57.133-2.602.159l-2.32.141c-.661.04-1.288.305-1.778.75l-3.538 3.212h-3.697l-3.536-3.306a2.9 2.9 0 0 0-1.69-.769q-.41 0-.79-.004c-1.906-.016-3.288-.063-4.384-.21-1.439-.194-1.762-.49-1.896-.623-.134-.134-.429-.458-.622-1.896l-.009-.065-.012-.013-.002-.027-.004-.108c-.13-1.084-.171-2.442-.185-4.283l-.02-.472a2.9 2.9 0 0 0-.755-1.833zM57.01 32.35l.19 2.586c.049.652.315 1.27.757 1.751l3.16 3.447v-3.367c0-6.544 0-9.815-2.032-11.848s-5.305-2.033-11.848-2.033H43.85l3.391 3.09c.475.432 1.08.696 1.721.748l3.933.322q.562.033 1.045.085l.29.024.013.012.066.01c1.438.192 1.762.488 1.895.621.134.134.43.458.623 1.896.098.733.152 1.595.182 2.655" />
<path d="m27.226 54.158-.013-.013.002.027.012.013zM29.849 56.78l4.289.199c-1.852-.015-3.208-.06-4.29-.198M27.044 49.476a3 3 0 0 0-.08-.57 3 3 0 0 1 .04.376l.02.472c.014 1.84.056 3.2.185 4.283l.004.108.013.013zM17.915 41.972c0 2.45 1.679 4.491 5.038 7.903q-.009-1.246-.007-2.698v-3.344l-.007-.008v-.005l-.052-.068c-.88-1.153-.9-1.59-.9-1.78s.02-.627.9-1.78l.059-.077v-3.348q-.001-1.452.006-2.698c-3.358 3.412-5.037 5.454-5.037 7.903M40.25 61.116l-.048-.037h-.01l-.022-.021h-3.344q-1.45.002-2.697-.007c3.412 3.358 5.453 5.038 7.902 5.038 2.45 0 4.491-1.68 7.903-5.038q-1.246.009-2.697.007h-3.35l-.075.058c-1.154.88-1.592.9-1.78.9-.19 0-.627-.02-1.781-.9" />
</g>
</svg>
);
};

View File

@@ -0,0 +1,79 @@
import { Trans } from '@lingui/react/macro';
import { ExternalLink, PaperclipIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string;
token: string;
};
export const DocumentSigningAttachmentsPopover = ({
envelopeId,
token,
}: DocumentSigningAttachmentsPopoverProps) => {
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
token,
});
if (!attachments || attachments.data.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<PaperclipIcon className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>{' '}
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="start">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Documents and resources related to this envelope.</Trans>
</p>
</div>
<div className="space-y-2">
{attachments?.data.map((attachment) => (
<a
key={attachment.id}
href={attachment.data}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
>
<div className="flex flex-1 items-center gap-2.5">
<div className="bg-muted rounded p-2">
<PaperclipIcon className="h-4 w-4" />
</div>
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
{attachment.label}
</span>
</div>
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
</a>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -18,7 +18,9 @@ import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
@@ -45,6 +47,7 @@ export type DocumentSigningCompleteDialogProps = {
onSignatureComplete: ( onSignatureComplete: (
nextSigner?: { name: string; email: string }, nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth, accessAuthOptions?: TRecipientAccessAuth,
directRecipient?: { name: string; email: string },
) => void | Promise<void>; ) => void | Promise<void>;
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>; recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
disabled?: boolean; disabled?: boolean;
@@ -53,6 +56,12 @@ export type DocumentSigningCompleteDialogProps = {
name: string; name: string;
email: string; email: string;
}; };
directTemplatePayload?: {
name: string;
email: string;
};
buttonSize?: 'sm' | 'lg';
position?: 'start' | 'end' | 'center';
}; };
const ZNextSignerFormSchema = z.object({ const ZNextSignerFormSchema = z.object({
@@ -63,6 +72,13 @@ const ZNextSignerFormSchema = z.object({
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>; type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
const ZDirectRecipientFormSchema = z.object({
name: z.string(),
email: z.string().email('Invalid email address'),
});
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
export const DocumentSigningCompleteDialog = ({ export const DocumentSigningCompleteDialog = ({
isSubmitting, isSubmitting,
documentTitle, documentTitle,
@@ -72,15 +88,19 @@ export const DocumentSigningCompleteDialog = ({
recipient, recipient,
disabled = false, disabled = false,
allowDictateNextSigner = false, allowDictateNextSigner = false,
directTemplatePayload,
defaultNextSigner, defaultNextSigner,
buttonSize = 'lg',
position,
}: DocumentSigningCompleteDialogProps) => { }: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui();
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false); const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null); const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext(); const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
const form = useForm<TNextSignerFormSchema>({ const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
@@ -90,6 +110,14 @@ export const DocumentSigningCompleteDialog = ({
}, },
}); });
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
name: directTemplatePayload?.name ?? '',
email: directTemplatePayload?.email ?? '',
},
});
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const completionRequires2FA = useMemo( const completionRequires2FA = useMemo(
@@ -109,12 +137,23 @@ export const DocumentSigningCompleteDialog = ({
}); });
} }
setIsEditingNextSigner(false);
setShowDialog(open); setShowDialog(open);
}; };
const onFormSubmit = async (data: TNextSignerFormSchema) => { const onFormSubmit = async (data: TNextSignerFormSchema) => {
try { try {
let directRecipient: { name: string; email: string } | undefined;
if (directTemplatePayload && !directTemplatePayload.email) {
const isFormValid = await directRecipientForm.trigger();
if (!isFormValid) {
return;
}
directRecipient = directRecipientForm.getValues();
}
// Check if 2FA is required // Check if 2FA is required
if (completionRequires2FA && !data.accessAuthOptions) { if (completionRequires2FA && !data.accessAuthOptions) {
setShowTwoFactorForm(true); setShowTwoFactorForm(true);
@@ -126,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email } ? { name: data.name, email: data.email }
: undefined; : undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions); await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
} catch (error) { } catch (error) {
const err = AppError.parseError(error); const err = AppError.parseError(error);
@@ -152,21 +191,19 @@ export const DocumentSigningCompleteDialog = ({
void form.handleSubmit(onFormSubmit)(); void form.handleSubmit(onFormSubmit)();
}; };
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return ( return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}> <Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
className="w-full" className="w-full"
type="button" type="button"
size="lg" size={buttonSize}
onClick={fieldsValidated} onClick={fieldsValidated}
loading={isSubmitting} loading={isSubmitting}
disabled={disabled} disabled={disabled}
> >
{match({ isComplete, role: recipient.role }) {match({ isComplete, role: recipient.role })
.with({ isComplete: false }, () => <Trans>Next field</Trans>) .with({ isComplete: false }, () => <Trans>Next Field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>) .with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => ( .with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
<Trans>Mark as viewed</Trans> <Trans>Mark as viewed</Trans>
@@ -176,106 +213,98 @@ export const DocumentSigningCompleteDialog = ({
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent position={position}>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<div className="text-muted-foreground max-w-[50ch]">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap">
<Trans>You are about to complete viewing the following document</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span className="inline-flex flex-wrap">
<Trans>You are about to complete signing the following document</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span className="inline-flex flex-wrap">
<Trans>You are about to complete approving the following document</Trans>
</span>
))
.with(RecipientRole.ASSISTANT, () => (
<span className="inline-flex flex-wrap">
<Trans>You are about to complete assisting the following document</Trans>
</span>
))
.with(RecipientRole.CC, () => null)
.exhaustive()}
</div>
</DialogDescription>
</DialogHeader>
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
</div>
{!showTwoFactorForm && ( {!showTwoFactorForm && (
<Form {...form}> <>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0"> {directTemplatePayload && !directTemplatePayload.email && (
<DialogTitle> <Form {...directRecipientForm}>
<div className="text-foreground text-xl font-semibold"> <div className="mb-4 flex flex-col gap-4">
{match(recipient.role) <div className="flex flex-col gap-4 md:flex-row">
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>) <FormField
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>) control={directRecipientForm.control}
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>) name="name"
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>) render={({ field }) => (
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>) <FormItem className="flex-1">
.exhaustive()} <FormLabel>
<Trans>Your Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={directRecipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Your Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder={t`Enter your email`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div> </div>
</DialogTitle> </Form>
)}
<div className="text-muted-foreground max-w-[50ch]"> <Form {...form}>
{match(recipient.role) <form onSubmit={form.handleSubmit(onFormSubmit)}>
.with(RecipientRole.VIEWER, () => ( {allowDictateNextSigner && defaultNextSigner && (
<span> <div className="mb-4 flex flex-col gap-4">
<Trans> {/* Todo: Envelopes - Should we say "The next recipient to sign this document will be"? */}
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))
.with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
))
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
<FormField <FormField
control={form.control} control={form.control}
@@ -283,13 +312,13 @@ export const DocumentSigningCompleteDialog = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel> <FormLabel>
<Trans>Name</Trans> <Trans>Next Recipient Name</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
className="mt-2" className="mt-2"
placeholder="Enter the next signer's name" placeholder={t`Enter the next signer's name`}
/> />
</FormControl> </FormControl>
@@ -304,14 +333,14 @@ export const DocumentSigningCompleteDialog = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel> <FormLabel>
<Trans>Email</Trans> <Trans>Next Recipient Email</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
type="email" type="email"
className="mt-2" className="mt-2"
placeholder="Enter the next signer's email" placeholder={t`Enter the next signer's email`}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -319,17 +348,14 @@ export const DocumentSigningCompleteDialog = ({
)} )}
/> />
</div> </div>
)} </div>
</div> )}
)}
<DocumentSigningDisclosure className="mt-4" /> <DocumentSigningDisclosure />
<DialogFooter className="mt-4"> <DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button
type="button" type="button"
className="flex-1"
variant="secondary" variant="secondary"
onClick={() => setShowDialog(false)} onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
@@ -339,8 +365,7 @@ export const DocumentSigningCompleteDialog = ({
<Button <Button
type="submit" type="submit"
className="flex-1" disabled={!isComplete}
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting} loading={form.formState.isSubmitting}
> >
{match(recipient.role) {match(recipient.role)
@@ -351,11 +376,11 @@ export const DocumentSigningCompleteDialog = ({
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>) .with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()} .exhaustive()}
</Button> </Button>
</div> </DialogFooter>
</DialogFooter> </form>
</fieldset> </Form>
</form> </fieldset>
</Form> </>
)} )}
{showTwoFactorForm && ( {showTwoFactorForm && (

View File

@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { match } from 'ts-pattern';
import { Button } from '@documenso/ui/primitives/button';
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
export const DocumentSigningMobileWidget = () => {
const [isExpanded, setIsExpanded] = useState(false);
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
useRequiredEnvelopeSigningContext();
/**
* Pre open the widget for assistants to let them know it's there.
*/
useEffect(() => {
if (recipient.role === RecipientRole.ASSISTANT) {
setIsExpanded(true);
}
}, []);
return (
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
<div className="pointer-events-auto w-full max-w-2xl">
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
{/* Main Header Bar */}
<div className="flex items-center justify-between gap-4 p-4">
<div className="flex-1">
<div className="flex items-center gap-3">
{recipient.role !== RecipientRole.VIEWER && (
<Button
variant="outline"
onClick={() => setIsExpanded(!isExpanded)}
className="flex h-8 w-8 items-center justify-center"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<LucideChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
) : (
<LucideChevronUp className="text-muted-foreground h-5 w-5 flex-shrink-0" />
)}
</Button>
)}
<div>
<h2 className="text-foreground text-lg font-semibold">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
</h2>
<p className="text-muted-foreground -mt-0.5 text-sm">
{recipientFieldsRemaining.length === 0 ? (
match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>Please mark as viewed to complete</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.with(RecipientRole.ASSISTANT, () => (
<Trans>Please complete the document once reviewed</Trans>
))
.otherwise(() => null)
) : (
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
)}
</p>
</div>
</div>
</div>
<div>
<EnvelopeSignerCompleteDialog />
</div>
</div>
{/* Progress Bar */}
{recipient.role !== RecipientRole.VIEWER &&
recipient.role !== RecipientRole.ASSISTANT && (
<div className="px-4 pb-3">
<div className="bg-muted relative h-[4px] rounded-md">
<motion.div
layout="size"
layoutId="document-signing-mobile-widget-progress-bar"
className="bg-documenso absolute inset-y-0 left-0"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
</div>
)}
{/* Expandable Content */}
{isExpanded && (
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
<EnvelopeSignerForm />
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -32,6 +32,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field'; import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@@ -231,7 +232,13 @@ export const DocumentSigningPageViewV1 = ({
</span> </span>
</div> </div>
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} /> <div className="flex items-center gap-x-4">
<DocumentSigningAttachmentsPopover
envelopeId={document.envelopeId}
token={recipient.token}
/>
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
</div>
</div> </div>
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0"> <div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">

View File

@@ -1,16 +1,20 @@
import { lazy } from 'react'; import { lazy, useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon } from 'lucide-react'; import { ArrowLeftIcon, BanIcon, DownloadCloudIcon } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { SignFieldCheckboxDialog } from '~/components/dialogs/sign-field-checkbox-dialog';
import { SignFieldDropdownDialog } from '~/components/dialogs/sign-field-dropdown-dialog'; import { SignFieldDropdownDialog } from '~/components/dialogs/sign-field-dropdown-dialog';
import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog'; import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog';
import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog'; import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog';
@@ -19,9 +23,12 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog'; import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog'; import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector'; import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form'; import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header'; import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
const EnvelopeSignerPageRenderer = lazy( const EnvelopeSignerPageRenderer = lazy(
@@ -31,11 +38,31 @@ const EnvelopeSignerPageRenderer = lazy(
export const DocumentSigningPageViewV2 = () => { export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { envelope, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip } = const {
useRequiredEnvelopeSigningContext(); isDirectTemplate,
envelope,
recipient,
recipientFields,
recipientFieldsRemaining,
requiredRecipientFields,
selectedAssistantRecipientFields,
} = useRequiredEnvelopeSigningContext();
/**
* The total remaining fields remaining for the current recipient or selected assistant recipient.
*
* Includes both optional and required fields.
*/
const remainingFields = useMemo(() => {
if (recipient.role === RecipientRole.ASSISTANT) {
return selectedAssistantRecipientFields.filter((field) => !field.inserted);
}
return recipientFields.filter((field) => !field.inserted);
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
return ( return (
<div className="h-screen w-screen bg-gray-50"> <div className="dark:bg-background min-h-screen w-screen bg-gray-50">
<SignFieldEmailDialog.Root /> <SignFieldEmailDialog.Root />
<SignFieldTextDialog.Root /> <SignFieldTextDialog.Root />
<SignFieldNumberDialog.Root /> <SignFieldNumberDialog.Root />
@@ -43,19 +70,29 @@ export const DocumentSigningPageViewV2 = () => {
<SignFieldInitialsDialog.Root /> <SignFieldInitialsDialog.Root />
<SignFieldDropdownDialog.Root /> <SignFieldDropdownDialog.Root />
<SignFieldSignatureDialog.Root /> <SignFieldSignatureDialog.Root />
<SignFieldCheckboxDialog.Root />
<EnvelopeSignerHeader /> <EnvelopeSignerHeader />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex h-[calc(100vh-73px)] w-screen"> <div className="flex h-[calc(100vh-73px)] w-screen">
{/* Left Section - Step Navigation */} {/* Left Section - Step Navigation */}
<div className="hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4 lg:flex"> <div className="bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
<div className="px-4"> <div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900"> <h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
<Trans>Sign Document</Trans> {match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs"> <span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
<Trans>{recipientFieldsRemaining.length} fields remaining</Trans> <Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
</span> </span>
</h3> </h3>
@@ -65,7 +102,7 @@ export const DocumentSigningPageViewV2 = () => {
layoutId="document-flow-container-step" layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0" className="bg-documenso absolute inset-y-0 left-0"
style={{ style={{
width: `${(100 / recipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`, width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}} }}
/> />
</div> </div>
@@ -78,27 +115,50 @@ export const DocumentSigningPageViewV2 = () => {
<Separator className="my-6" /> <Separator className="my-6" />
{/* Quick Actions. */} {/* Quick Actions. */}
<div className="space-y-3 px-4"> {!isDirectTemplate && (
<h4 className="text-sm font-semibold text-gray-900"> <div className="space-y-3 px-4">
<Trans>Actions</Trans> <h4 className="text-foreground text-sm font-semibold">
</h4> <Trans>Actions</Trans>
</h4>
{/* Todo: Allow selecting which document to download and/or the original */} <div className="w-full">
<Button variant="ghost" size="sm" className="w-full justify-start"> <DocumentSigningAttachmentsPopover
<DownloadCloudIcon className="mr-2 h-4 w-4" /> envelopeId={envelope.id}
<Trans>Download Original</Trans> token={recipient.token}
</Button> />
</div>
{/* Todo: Envelopes */} <EnvelopeDownloadDialog
<Button envelopeId={envelope.id}
variant="ghost" envelopeStatus={envelope.status}
size="sm" envelopeItems={envelope.envelopeItems}
className="hover:text-destructive w-full justify-start" token={recipient.token}
> trigger={
<BanIcon className="mr-2 h-4 w-4" /> <Button variant="ghost" size="sm" className="w-full justify-start">
<Trans>Reject Document</Trans> <DownloadCloudIcon className="mr-2 h-4 w-4" />
</Button> <Trans>Download PDF</Trans>
</div> </Button>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
trigger={
<Button
variant="ghost"
size="sm"
className="hover:text-destructive w-full justify-start"
>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
}
/>
)}
</div>
)}
{/* Footer of left sidebar. */} {/* Footer of left sidebar. */}
<div className="mt-auto px-4"> <div className="mt-auto px-4">
@@ -111,47 +171,34 @@ export const DocumentSigningPageViewV2 = () => {
</div> </div>
</div> </div>
{/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex flex-col"> <div className="flex flex-col">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<div className="flex h-fit space-x-2 overflow-x-auto p-4"> {envelopeItems.length > 1 && (
{envelopeItems.map((doc, i) => ( <div className="flex h-fit space-x-2 overflow-x-auto p-2 pt-4 sm:p-4">
<EnvelopeItemSelector {envelopeItems.map((doc, i) => (
key={doc.id} <EnvelopeItemSelector
number={i + 1} key={doc.id}
primaryText={doc.title} number={i + 1}
secondaryText={ primaryText={doc.title}
<Plural secondaryText={
one="1 Field" <Plural
other="# Fields" one="1 Field"
value={ other="# Fields"
recipientFieldsRemaining.filter((field) => field.envelopeItemId === doc.id) value={
.length remainingFields.filter((field) => field.envelopeItemId === doc.id).length
} }
/> />
} }
isSelected={currentEnvelopeItem?.id === doc.id} isSelected={currentEnvelopeItem?.id === doc.id}
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }} buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
/> />
))} ))}
</div> </div>
)}
{/* Document View */} {/* Document View */}
<div className="mt-4 flex justify-center p-4"> <div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem &&
showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && (
<FieldToolTip
key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]}
color="warning"
>
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
{currentEnvelopeItem ? ( {currentEnvelopeItem ? (
<PDFViewerKonvaLazy <PDFViewerKonvaLazy
key={currentEnvelopeItem.id} key={currentEnvelopeItem.id}
@@ -165,6 +212,11 @@ export const DocumentSigningPageViewV2 = () => {
</p> </p>
</div> </div>
)} )}
{/* Mobile widget - Additional padding to allow users to scroll */}
<div className="block pb-16 md:hidden">
<DocumentSigningMobileWidget />
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -39,12 +39,14 @@ export interface DocumentSigningRejectDialogProps {
documentId: number; documentId: number;
token: string; token: string;
onRejected?: (reason: string) => void | Promise<void>; onRejected?: (reason: string) => void | Promise<void>;
trigger?: React.ReactNode;
} }
export function DocumentSigningRejectDialog({ export function DocumentSigningRejectDialog({
documentId, documentId,
token, token,
onRejected, onRejected,
trigger,
}: DocumentSigningRejectDialogProps) { }: DocumentSigningRejectDialogProps) {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -108,9 +110,11 @@ export function DocumentSigningRejectDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline"> {trigger ?? (
<Trans>Reject Document</Trans> <Button variant="outline">
</Button> <Trans>Reject Document</Trans>
</Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useMemo, useState } from 'react'; import { createContext, useContext, useMemo, useState } from 'react';
import { import {
EnvelopeType,
type Field, type Field,
FieldType, FieldType,
type Recipient, type Recipient,
@@ -11,11 +12,17 @@ import {
import { isBase64Image } from '@documenso/lib/constants/signatures'; import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; 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 { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types'; import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
export type EnvelopeSigningContextValue = { export type EnvelopeSigningContextValue = {
isDirectTemplate: boolean;
fullName: string; fullName: string;
setFullName: (_value: string) => void; setFullName: (_value: string) => void;
email: string; email: string;
@@ -32,7 +39,8 @@ export type EnvelopeSigningContextValue = {
recipient: EnvelopeForSigningResponse['recipient']; recipient: EnvelopeForSigningResponse['recipient'];
recipientFieldsRemaining: Field[]; recipientFieldsRemaining: Field[];
recipientFields: Field[]; recipientFields: Field[];
selectedRecipientFields: Field[]; requiredRecipientFields: Field[];
selectedAssistantRecipientFields: Field[];
nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null; nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
otherRecipientCompletedFields: (Field & { otherRecipientCompletedFields: (Field & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>; recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>;
@@ -85,26 +93,31 @@ export const EnvelopeSigningProvider = ({
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const { const isDirectTemplate = envelope.type === EnvelopeType.TEMPLATE;
mutateAsync: completeDocument,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({ const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (data) => { onSuccess: (data) => {
console.log('signEnvelopeField', data);
const newRecipientFields = envelopeData.recipient.fields.map((field) =>
field.id === data.signedField.id ? data.signedField : field,
);
setEnvelopeData((prev) => ({ setEnvelopeData((prev) => ({
...prev, ...prev,
envelope: {
...prev.envelope,
recipients: prev.envelope.recipients.map((recipient) =>
recipient.id === data.signedField.recipientId
? {
...recipient,
fields: recipient.fields.map((field) =>
field.id === data.signedField.id ? data.signedField : field,
),
}
: recipient,
),
},
recipient: { recipient: {
...prev.recipient, ...prev.recipient,
fields: newRecipientFields, fields: prev.recipient.fields.map((field) =>
field.id === data.signedField.id ? data.signedField : field,
),
}, },
})); }));
}, },
@@ -148,6 +161,27 @@ export const EnvelopeSigningProvider = ({
})(), })(),
); );
/**
* The fields that are still required to be signed by the actual recipient.
*/
const recipientFieldsRemaining = useMemo(() => {
return envelopeData.recipient.fields.filter((field) => isFieldUnsignedAndRequired(field));
}, [envelopeData.recipient.fields]);
/**
* All the required fields for the actual recipient.
*/
const requiredRecipientFields = useMemo(() => {
return envelopeData.recipient.fields.filter((field) => isRequiredField(field));
}, [envelopeData.recipient.fields]);
/**
* All the fields for the actual recipient.
*/
const recipientFields = useMemo(() => {
return envelopeData.recipient.fields;
}, [envelopeData.recipient.fields]);
/** /**
* Assistant recipients are those that have a signing order after the assistant. * Assistant recipients are those that have a signing order after the assistant.
*/ */
@@ -181,22 +215,8 @@ export const EnvelopeSigningProvider = ({
return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null; return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null;
}, [envelope.recipients, selectedAssistantRecipientId]); }, [envelope.recipients, selectedAssistantRecipientId]);
/** const selectedAssistantRecipientFields = useMemo(() => {
* The fields that are still required to be signed by the current recipient. return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
*/
const recipientFieldsRemaining = useMemo(() => {
return envelopeData.recipient.fields.filter((field) => isFieldUnsignedAndRequired(field));
}, [envelopeData.recipient.fields]);
/**
* All the fields for the current recipient.
*/
const recipientFields = useMemo(() => {
return envelopeData.recipient.fields;
}, [envelopeData.recipient.fields]);
const selectedRecipientFields = useMemo(() => {
return recipientFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
}, [recipientFields, selectedAssistantRecipient]); }, [recipientFields, selectedAssistantRecipient]);
/** /**
@@ -244,6 +264,12 @@ export const EnvelopeSigningProvider = ({
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => { const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
console.log('insertField', fieldId, fieldValue); console.log('insertField', fieldId, fieldValue);
// Set the field locally for direct templates.
if (isDirectTemplate) {
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
return;
}
await signEnvelopeField({ await signEnvelopeField({
token: envelopeData.recipient.token, token: envelopeData.recipient.token,
fieldId, fieldId,
@@ -252,9 +278,67 @@ export const EnvelopeSigningProvider = ({
}); });
}; };
const handleDirectTemplateFieldInsertion = (
fieldId: number,
fieldValue: TSignEnvelopeFieldValue,
) => {
const foundField = recipient.fields.find((field) => field.id === fieldId);
if (!foundField) {
throw new Error('Not possible');
}
const insertionValues = extractFieldInsertionValues({
fieldValue,
field: foundField,
documentMeta: envelope.documentMeta,
});
const updatedField = {
...foundField,
...insertionValues,
};
if (fieldValue.type === FieldType.SIGNATURE) {
const isBase64 = isBase64Image(fieldValue.value || '');
updatedField.signature = fieldValue.value
? {
signatureImageAsBase64: isBase64 ? fieldValue.value : null,
typedSignature: isBase64 ? null : fieldValue.value,
recipientId: recipient.id,
created: new Date(),
// Dummy IDs.
id: 0,
fieldId: 0,
}
: null;
}
setEnvelopeData((prev) => ({
...prev,
envelope: {
...prev.envelope,
recipients: prev.envelope.recipients.map((r) =>
r.id === recipient.id
? {
...r,
fields: r.fields.map((field) => (field.id === fieldId ? updatedField : field)),
}
: r,
),
},
recipient: {
...prev.recipient,
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
},
}));
};
return ( return (
<EnvelopeSigningContext.Provider <EnvelopeSigningContext.Provider
value={{ value={{
isDirectTemplate,
fullName, fullName,
setFullName, setFullName,
email, email,
@@ -270,6 +354,7 @@ export const EnvelopeSigningProvider = ({
recipient, recipient,
recipientFieldsRemaining, recipientFieldsRemaining,
recipientFields, recipientFields,
requiredRecipientFields,
nextRecipient, nextRecipient,
otherRecipientCompletedFields, otherRecipientCompletedFields,
@@ -277,7 +362,7 @@ export const EnvelopeSigningProvider = ({
assistantFields, assistantFields,
setSelectedAssistantRecipientId, setSelectedAssistantRecipientId,
selectedAssistantRecipient, selectedAssistantRecipient,
selectedRecipientFields, selectedAssistantRecipientFields,
signField, signField,
}} }}

View File

@@ -0,0 +1,248 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
};
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
buttonSize,
}: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const form = useForm<TAttachmentFormSchema>({
resolver: zodResolver(ZAttachmentFormSchema),
defaultValues: {
label: '',
url: '',
},
});
const onSubmit = async (data: TAttachmentFormSchema) => {
try {
await createAttachment({
envelopeId,
data: {
label: data.label,
data: data.url,
},
});
form.reset();
setIsAdding(false);
toast({
title: _(msg`Success`),
description: _(msg`Attachment added successfully.`),
});
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Error`),
description: error.message,
variant: 'destructive',
});
}
};
const onDeleteAttachment = async (id: string) => {
try {
await deleteAttachment({ id });
toast({
title: _(msg`Success`),
description: _(msg`Attachment removed successfully.`),
});
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Error`),
description: error.message,
variant: 'destructive',
});
}
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
{attachments && attachments.data.length > 0 && (
<div className="space-y-2">
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
>
{attachment.data}
</a>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void onDeleteAttachment(attachment.id)}
className="ml-2 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{!isAdding && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsAdding(true)}
>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
)}
{isAdding && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={_(msg`Label`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="url" placeholder={_(msg`URL`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setIsAdding(false);
form.reset();
}}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
<Trans>Add</Trans>
</Button>
</div>
</form>
</Form>
)}
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
try { try {
setIsLoading(true); setIsLoading(true);
const response = await putPdfFile(file); const payload = {
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
documentDataId: response.id, timezone: userTimezone,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
}); } satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits(); void refreshLimits();
@@ -95,6 +99,10 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
AppErrorCode.LIMIT_EXCEEDED, AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`, () => msg`You have reached your document limit for this month. Please upgrade your plan.`,
) )
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => msg`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => msg`An error occurred while uploading your document.`); .otherwise(() => msg`An error occurred while uploading your document.`);
toast({ toast({

View File

@@ -14,6 +14,8 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
export type DocumentPageViewButtonProps = { export type DocumentPageViewButtonProps = {
envelope: TEnvelope; envelope: TEnvelope;
}; };
@@ -59,6 +61,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
isPending, isPending,
isComplete, isComplete,
isSigned, isSigned,
internalVersion: envelope.internalVersion,
}) })
.with({ isRecipient: true, isPending: true, isSigned: false }, () => ( .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-full" asChild> <Button className="w-full" asChild>
@@ -92,6 +95,20 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
</Link> </Link>
</Button> </Button>
)) ))
.with({ isComplete: true, internalVersion: 2 }, () => (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient?.token}
trigger={
<Button className="w-full">
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
}
/>
))
.with({ isComplete: true }, () => ( .with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}> <Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" /> <Download className="-ml-1 mr-2 inline h-4 w-4" />

View File

@@ -36,6 +36,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog'; import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog'; import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog'; import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
@@ -146,17 +147,36 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isComplete && ( {envelope.internalVersion === 2 ? (
<DropdownMenuItem onClick={onDownloadClick}> <EnvelopeDownloadDialog
<Download className="mr-2 h-4 w-4" /> envelopeId={envelope.id}
<Trans>Download</Trans> envelopeStatus={envelope.status}
</DropdownMenuItem> token={recipient?.token}
)} envelopeItems={envelope.envelopeItems}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
</DropdownMenuItem>
}
/>
) : (
<>
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onDownloadOriginalClick}> <DropdownMenuItem onClick={onDownloadOriginalClick}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
<Trans>Download Original</Trans> <Trans>Download Original</Trans>
</DropdownMenuItem> </DropdownMenuItem>
</>
)}
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to={`${documentsPath}/${envelope.id}/logs`}> <Link to={`${documentsPath}/${envelope.id}/logs`}>

View File

@@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; 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 { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
@@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
try { try {
setIsLoading(true); setIsLoading(true);
const response = await putPdfFile(file); const payload = {
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
documentDataId: response.id,
timezone: userTimezone, timezone: userTimezone,
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
}); } satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits(); void refreshLimits();
@@ -108,6 +112,10 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
AppErrorCode.LIMIT_EXCEEDED, AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`, () => msg`You have reached your document limit for this month. Please upgrade your plan.`,
) )
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => msg`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => msg`An error occurred while uploading your document.`); .otherwise(() => msg`An error occurred while uploading your document.`);
toast({ toast({

View File

@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@@ -13,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
@@ -51,7 +52,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone, (timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
); );
const { quota, remaining, refreshLimits } = useLimits(); const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -69,6 +70,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
if (!user.emailVerified) { if (!user.emailVerified) {
return msg`Verify your email to upload documents.`; return msg`Verify your email to upload documents.`;
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [remaining.documents, user.emailVerified, team]); }, [remaining.documents, user.emailVerified, team]);
@@ -76,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try { try {
setIsLoading(true); setIsLoading(true);
const result = await Promise.all( const payload = {
files.map(async (file) => {
try {
const response = await putPdfFile(file);
return {
title: file.name,
documentDataId: response.id,
};
} catch (err) {
console.error(err);
throw new Error('Failed to upload document');
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { id } = await createEnvelope({
folderId, folderId,
type, type,
title: files[0].name, title: files[0].name,
items: envelopeItemsToCreate,
meta: { meta: {
timezone: userTimezone, timezone: userTimezone,
}, },
}).catch((error) => { } satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData).catch((error) => {
console.error(error); console.error(error);
throw error; throw error;
@@ -138,6 +129,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
AppErrorCode.LIMIT_EXCEEDED, AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`, () => t`You have reached your document limit for this month. Please upgrade your plan.`,
) )
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => t`An error occurred while uploading your document.`); .otherwise(() => t`An error occurred while uploading your document.`);
toast({ toast({
@@ -151,12 +146,23 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
} }
}; };
const onFileDropRejected = () => { const onFileDropRejected = (fileRejections: FileRejection[]) => {
const maxItemsReached = fileRejections.some((fileRejection) =>
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
);
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
return;
}
toast({ toast({
title: title: t`Upload failed`,
type === EnvelopeType.DOCUMENT
? t`Your document failed to upload.`
: t`Your template failed to upload.`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`, description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000, duration: 5000,
variant: 'destructive', variant: 'destructive',
@@ -176,6 +182,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
onDrop={onFileDrop} onDrop={onFileDrop}
onDropRejected={onFileDropRejected} onDropRejected={onFileDropRejected}
type="envelope" type="envelope"
maxFiles={maximumEnvelopeItemCount}
/> />
</div> </div>
</TooltipTrigger> </TooltipTrigger>

View File

@@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId, selectedRecipientId,
selectedEnvelopeItemId, selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => { }: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor(); const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { t } = useLingui(); const { t } = useLingui();
@@ -262,6 +262,10 @@ export const EnvelopeEditorFieldDragDrop = ({
}; };
}, [onMouseClick, onMouseMove, selectedField]); }, [onMouseClick, onMouseMove, selectedField]);
const selectedRecipientColor = useMemo(() => {
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
}, [selectedRecipientId, getRecipientColorKey]);
return ( return (
<> <>
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5"> <div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
@@ -273,12 +277,23 @@ export const EnvelopeEditorFieldDragDrop = ({
onClick={() => setSelectedField(field.type)} onClick={() => setSelectedField(field.type)}
onMouseDown={() => setSelectedField(field.type)} onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined} data-selected={selectedField === field.type ? true : undefined}
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50" className={cn(
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
)}
> >
<p <p
className={cn( className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal', 'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
field.className, field.className,
{
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
},
)} )}
> >
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />} {field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
@@ -291,9 +306,9 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && ( {selectedField && (
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]', 'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
// selectedSignerStyles?.base, RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes selectedField === FieldType.SIGNATURE && 'font-signature',
{ {
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds, 'dark:text-black/60': isFieldWithinBounds,

View File

@@ -3,15 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client'; import type { FieldType } from '@prisma/client';
import Konva from 'konva'; import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer'; import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react'; import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
@@ -21,32 +18,16 @@ import {
convertPixelToPercentage, convertPixelToPercentage,
} from '@documenso/lib/universal/field-renderer/field-renderer'; } from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() { export default function EnvelopeEditorFieldsPageRenderer() {
const pageContext = usePageContext(); const { t, i18n } = useLingui();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const interactiveTransformer = useRef<Transformer | null>(null); const interactiveTransformer = useRef<Transformer | null>(null);
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]); const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
@@ -54,10 +35,17 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false); const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null); const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const viewport = useMemo( const {
() => page.getViewport({ scale, rotation: rotate }), stage,
[page, rotate, scale], pageLayer,
); canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
@@ -68,44 +56,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[editorFields.localFields, pageContext.pageNumber], [editorFields.localFields, pageContext.pageNumber],
); );
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => { const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
console.log('Field resized or moved'); console.log('Field resized or moved');
@@ -120,6 +70,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const fieldGroup = event.target as Konva.Group; const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id(); const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const { const {
width: fieldPixelWidth, width: fieldPixelWidth,
height: fieldPixelHeight, height: fieldPixelHeight,
@@ -130,7 +81,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
skipShadow: true, skipShadow: true,
}); });
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container); const pageHeight = scaledViewport.height;
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height // Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100; const positionPercentX = (fieldX / pageWidth) * 100;
@@ -165,8 +117,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}; };
const renderFieldOnLayer = (field: TLocalField) => { const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current || !interactiveTransformer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet');
return; return;
} }
@@ -174,7 +125,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const isFieldEditable = const isFieldEditable =
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields); recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup, isFirstRender } = renderField({ const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: field.formId, renderId: field.formId,
@@ -183,8 +135,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
inserted: false, inserted: false,
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
}, },
pageWidth: viewport.width, translations: getClientSideFieldTranslations(i18n),
pageHeight: viewport.height, pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId), color: getRecipientColorKey(field.recipientId),
editable: isFieldEditable, editable: isFieldEditable,
mode: 'edit', mode: 'edit',
@@ -210,24 +163,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}; };
/** /**
* Create the initial Konva page canvas and initialize all fields and interactions. * Initialize the Konva page canvas and all fields and interactions.
*/ */
const createPageCanvas = (container: HTMLDivElement) => { const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Initialize snap guides layer // Initialize snap guides layer
// snapGuideLayer.current = initializeSnapGuides(stage.current); // snapGuideLayer.current = initializeSnapGuides(stage.current);
// Add transformer for resizing and rotating. // Add transformer for resizing and rotating.
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current); interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
@@ -235,12 +178,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
// Handle stage click to deselect. // Handle stage click to deselect.
stage.current?.on('click', (e) => { currentStage.on('mousedown', (e) => {
removePendingField(); removePendingField();
if (e.target === stage.current) { if (e.target === stage.current) {
setSelectedFields([]); setSelectedFields([]);
pageLayer.current?.batchDraw(); currentPageLayer.batchDraw();
} }
}); });
@@ -267,12 +210,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setSelectedFields([e.target]); setSelectedFields([e.target]);
}; };
stage.current?.on('dragstart', onDragStartOrEnd); currentStage.on('dragstart', onDragStartOrEnd);
stage.current?.on('dragend', onDragStartOrEnd); currentStage.on('dragend', onDragStartOrEnd);
stage.current?.on('transformstart', () => setIsFieldChanging(true)); currentStage.on('transformstart', () => setIsFieldChanging(true));
stage.current?.on('transformend', () => setIsFieldChanging(false)); currentStage.on('transformend', () => setIsFieldChanging(false));
pageLayer.current.batchDraw(); currentPageLayer.batchDraw();
}; };
/** /**
@@ -284,7 +227,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
* - Selecting multiple fields * - Selecting multiple fields
* - Selecting empty area to create fields * - Selecting empty area to create fields
*/ */
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => { const createInteractiveTransformer = (
currentStage: Konva.Stage,
currentPageLayer: Konva.Layer,
) => {
const transformer = new Konva.Transformer({ const transformer = new Konva.Transformer({
rotateEnabled: false, rotateEnabled: false,
keepRatio: false, keepRatio: false,
@@ -301,36 +247,39 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}, },
}); });
layer.add(transformer); currentPageLayer.add(transformer);
// Add selection rectangle. // Add selection rectangle.
const selectionRectangle = new Konva.Rect({ const selectionRectangle = new Konva.Rect({
fill: 'rgba(24, 160, 251, 0.3)', fill: 'rgba(24, 160, 251, 0.3)',
visible: false, visible: false,
}); });
layer.add(selectionRectangle); currentPageLayer.add(selectionRectangle);
let x1: number; let x1: number;
let y1: number; let y1: number;
let x2: number; let x2: number;
let y2: number; let y2: number;
stage.on('mousedown touchstart', (e) => { currentStage.on('mousedown touchstart', (e) => {
// do nothing if we mousedown on any shape // do nothing if we mousedown on any shape
if (e.target !== stage) { if (e.target !== currentStage) {
return; return;
} }
const pointerPosition = stage.getPointerPosition(); const pointerPosition = currentStage.getPointerPosition();
if (!pointerPosition) { if (!pointerPosition) {
return; return;
} }
x1 = pointerPosition.x; console.log(`pointerPosition.x: ${pointerPosition.x}`);
y1 = pointerPosition.y; console.log(`pointerPosition.y: ${pointerPosition.y}`);
x2 = pointerPosition.x;
y2 = pointerPosition.y; x1 = pointerPosition.x / scale;
y1 = pointerPosition.y / scale;
x2 = pointerPosition.x / scale;
y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: x1, x: x1,
@@ -341,7 +290,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}); });
}); });
stage.on('mousemove touchmove', () => { currentStage.on('mousemove touchmove', () => {
// do nothing if we didn't start selection // do nothing if we didn't start selection
if (!selectionRectangle.visible()) { if (!selectionRectangle.visible()) {
return; return;
@@ -349,14 +298,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.moveToTop(); selectionRectangle.moveToTop();
const pointerPosition = stage.getPointerPosition(); const pointerPosition = currentStage.getPointerPosition();
if (!pointerPosition) { if (!pointerPosition) {
return; return;
} }
x2 = pointerPosition.x; x2 = pointerPosition.x / scale;
y2 = pointerPosition.y; y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: Math.min(x1, x2), x: Math.min(x1, x2),
@@ -366,7 +315,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}); });
}); });
stage.on('mouseup touchend', () => { currentStage.on('mouseup touchend', () => {
// do nothing if we didn't start selection // do nothing if we didn't start selection
if (!selectionRectangle.visible()) { if (!selectionRectangle.visible()) {
return; return;
@@ -377,38 +326,41 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.visible(false); selectionRectangle.visible(false);
}); });
const stageFieldGroups = stage.find('.field-group') || []; const stageFieldGroups = currentStage.find('.field-group') || [];
const box = selectionRectangle.getClientRect(); const box = selectionRectangle.getClientRect();
const selectedFieldGroups = stageFieldGroups.filter( const selectedFieldGroups = stageFieldGroups.filter(
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(), (shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
); );
setSelectedFields(selectedFieldGroups); setSelectedFields(selectedFieldGroups);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small. // Create a field if no items are selected or the size is too small.
if ( if (
selectedFieldGroups.length === 0 && selectedFieldGroups.length === 0 &&
canvasElement.current && canvasElement.current &&
box.width > MIN_FIELD_WIDTH_PX && unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
box.height > MIN_FIELD_HEIGHT_PX && unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient && editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) { ) {
const pendingFieldCreation = new Konva.Rect({ const pendingFieldCreation = new Konva.Rect({
name: 'pending-field-creation', name: 'pending-field-creation',
x: box.x, x: box.x / scale,
y: box.y, y: box.y / scale,
width: box.width, width: unscaledBoxWidth,
height: box.height, height: unscaledBoxHeight,
fill: 'rgba(24, 160, 251, 0.3)', fill: 'rgba(24, 160, 251, 0.3)',
}); });
layer.add(pendingFieldCreation); currentPageLayer.add(pendingFieldCreation);
setPendingFieldCreation(pendingFieldCreation); setPendingFieldCreation(pendingFieldCreation);
} }
}); });
// Clicks should select/deselect shapes // Clicks should select/deselect shapes
stage.on('click tap', function (e) { currentStage.on('click tap', function (e) {
// if we are selecting with rect, do nothing // if we are selecting with rect, do nothing
if ( if (
selectionRectangle.visible() && selectionRectangle.visible() &&
@@ -419,7 +371,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
// If empty area clicked, remove all selections // If empty area clicked, remove all selections
if (e.target === stage) { if (e.target === stage.current) {
setSelectedFields([]); setSelectedFields([]);
return; return;
} }
@@ -468,20 +420,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
group.name() === 'field-group' && group.name() === 'field-group' &&
!localPageFields.some((field) => field.formId === group.id()) !localPageFields.some((field) => field.formId === group.id())
) { ) {
console.log('Field removed, removing from canvas');
group.destroy(); group.destroy();
} }
}); });
// If it exists, rerender. // If it exists, rerender.
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field); renderFieldOnLayer(field);
}); });
// If it doesn't exist, render it.
//
// Rerender the transformer // Rerender the transformer
interactiveTransformer.current?.forceUpdate(); interactiveTransformer.current?.forceUpdate();
@@ -555,15 +502,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return; return;
} }
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({ const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
width: pixelWidth, width: pixelWidth,
height: pixelHeight, height: pixelHeight,
positionX: pixelX, positionX: pixelX,
positionY: pixelY, positionY: pixelY,
pageWidth, pageWidth: unscaledViewport.width,
pageHeight, pageHeight: unscaledViewport.height,
}); });
editorFields.addField({ editorFields.addField({
@@ -597,7 +542,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
return ( return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}> <div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 && {selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current && interactiveTransformer.current &&
!isFieldChanging && ( !isFieldChanging && (
@@ -649,17 +597,23 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div> </div>
)} )}
{/* Todo: Envelopes - This will not overflow the page when close to edges */}
{pendingFieldCreation && ( {pendingFieldCreation && (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px', top:
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px', pendingFieldCreation.y() * scale +
pendingFieldCreation.getClientRect().height +
5 +
'px',
left:
pendingFieldCreation.x() * scale +
pendingFieldCreation.getClientRect().width / 2 +
'px',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
zIndex: 50, zIndex: 50,
}} }}
className="text-muted-foreground grid w-fit grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm" className="text-muted-foreground grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
> >
{fieldButtonList.map((field) => ( {fieldButtonList.map((field) => (
<button <button
@@ -673,13 +627,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div> </div>
)} )}
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div> {/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
width={viewport.width} height={scaledViewport.height}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`, [FieldType.DROPDOWN]: msg`Dropdown Settings`,
}; };
export const EnvelopeEditorPageFields = () => { export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor(); const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -109,7 +109,7 @@ export const EnvelopeEditorPageFields = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center p-4">
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : ( ) : (
@@ -128,10 +128,10 @@ export const EnvelopeEditorPageFields = () => {
{/* Right Section - Form Fields Panel */} {/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && ( {currentEnvelopeItem && (
<div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4"> <div className="bg-background border-border sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l py-4">
{/* Recipient selector section. */} {/* Recipient selector section. */}
<section className="px-4"> <section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900"> <h3 className="text-foreground mb-2 text-sm font-semibold">
<Trans>Selected Recipient</Trans> <Trans>Selected Recipient</Trans>
</h3> </h3>
@@ -170,7 +170,7 @@ export const EnvelopeEditorPageFields = () => {
{/* Add fields section. */} {/* Add fields section. */}
<section className="px-4"> <section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900"> <h3 className="text-foreground mb-2 text-sm font-semibold">
<Trans>Add Fields</Trans> <Trans>Add Fields</Trans>
</h3> </h3>

View File

@@ -13,7 +13,6 @@ import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@@ -22,8 +21,8 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog'; import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog'; import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team';
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge'; import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input'; import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@@ -31,30 +30,35 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() { export default function EnvelopeEditorHeader() {
const { t } = useLingui(); const { t } = useLingui();
const team = useCurrentTeam(); const {
envelope,
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError } = isDocument,
useCurrentEnvelopeEditor(); isTemplate,
updateEnvelope,
// Todo: Envelopes this probably won't work with embed? Maybe hide the back items when no team? autosaveError,
relativePath,
const rootPath = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url); syncEnvelope,
editorFields,
} = useCurrentEnvelopeEditor();
return ( return (
<nav className="w-full border-b border-gray-200 bg-white px-6 py-3"> <nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Link to="/"> <Link to="/">
<BrandingLogo className="h-6 w-auto" /> <BrandingLogo className="h-6 w-auto" />
</Link> </Link>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<EnvelopeItemTitleInput <EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT} disabled={envelope.status !== DocumentStatus.DRAFT}
value={envelope.title} value={envelope.title}
onChange={(title) => { onChange={(title) => {
updateEnvelope({ updateEnvelope({
title, data: {
title,
},
}); });
}} }}
placeholder={t`Envelope Title`} placeholder={t`Envelope Title`}
@@ -131,6 +135,8 @@ export default function EnvelopeEditorHeader() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
<EnvelopeEditorSettingsDialog <EnvelopeEditorSettingsDialog
trigger={ trigger={
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
@@ -142,7 +148,11 @@ export default function EnvelopeEditorHeader() {
{isDocument && ( {isDocument && (
<> <>
<EnvelopeDistributeDialog <EnvelopeDistributeDialog
envelope={envelope} envelope={{
...envelope,
fields: editorFields.localFields,
}}
onDistribute={syncEnvelope}
trigger={ trigger={
<Button size="sm"> <Button size="sm">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="mr-2 h-4 w-4" />
@@ -165,10 +175,11 @@ export default function EnvelopeEditorHeader() {
{isTemplate && ( {isTemplate && (
<TemplateUseDialog <TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)} templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder} templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients} recipients={envelope.recipients}
documentRootPath={rootPath} documentRootPath={relativePath.documentRootPath}
trigger={ trigger={
<Button size="sm"> <Button size="sm">
<Trans>Use Template</Trans> <Trans>Use Template</Trans>

View File

@@ -1,176 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeEditorPagePreviewRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
renderField({
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: 'export',
});
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
pageLayer.current.batchDraw();
};
/**
* Render fields when they are added or removed from the localFields.
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
return;
}
// If doesn't exist in localFields, destroy it since it's been deleted.
pageLayer.current.find('Group').forEach((group) => {
if (
group.name() === 'field-group' &&
!localPageFields.some((field) => field.formId === group.id())
) {
console.log('Field removed, removing from canvas');
group.destroy();
}
});
// If it exists, rerender.
localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
}, [localPageFields]);
if (!currentEnvelopeItem) {
return null;
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { lazy, useEffect, useState } from 'react'; import { lazy, useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { FileTextIcon } from 'lucide-react'; import { ConstructionIcon, FileTextIcon } from 'lucide-react';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
@@ -13,11 +13,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeEditorPagePreviewRenderer = lazy( const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
async () => import('./envelope-editor-page-preview-renderer'),
);
export const EnvelopeEditorPagePreview = () => { export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor(); const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -50,19 +48,35 @@ export const EnvelopeEditorPagePreview = () => {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{currentEnvelopeItem !== null ? ( {/* Coming soon section */}
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} /> <div className="border-border bg-card hover:bg-accent/10 flex w-full max-w-[800px] items-center gap-4 rounded-lg border p-4 transition-colors">
) : ( <div className="flex w-full flex-col items-center justify-center gap-2 py-32">
<div className="flex flex-col items-center justify-center py-32"> <ConstructionIcon className="text-muted-foreground h-10 w-10" />
<FileTextIcon className="text-muted-foreground h-10 w-10" /> <h3 className="text-foreground text-sm font-semibold">
<p className="text-foreground mt-1 text-sm"> <Trans>Coming soon</Trans>
<Trans>No documents found</Trans> </h3>
</p> <p className="text-muted-foreground text-sm">
<p className="text-muted-foreground mt-1 text-sm"> <Trans>This feature is coming soon</Trans>
<Trans>Please upload a document to continue</Trans>
</p> </p>
</div> </div>
)} </div>
{/* Todo: Envelopes - Remove div after preview mode is implemented */}
<div className="hidden">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
<p className="text-foreground mt-1 text-sm">
<Trans>No documents found</Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
)}
</div>
</div> </div>
</div> </div>

View File

@@ -75,7 +75,6 @@ const ZEnvelopeRecipientsForm = z.object({
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}), }),
), ),
// Todo: Envelopes - These aren't synced to the server
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false), allowDictateNextSigner: z.boolean().default(false),
}); });
@@ -83,7 +82,7 @@ const ZEnvelopeRecipientsForm = z.object({
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>; type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
export const EnvelopeEditorRecipientForm = () => { export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced } = useCurrentEnvelopeEditor(); const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
@@ -451,6 +450,8 @@ export const EnvelopeEditorRecipientForm = () => {
shouldValidate: true, shouldValidate: true,
shouldDirty: true, shouldDirty: true,
}); });
void form.trigger();
}, [form]); }, [form]);
// Dupecode/Inefficient: Done because native isValid won't work for our usecase. // Dupecode/Inefficient: Done because native isValid won't work for our usecase.
@@ -460,15 +461,39 @@ export const EnvelopeEditorRecipientForm = () => {
return; return;
} }
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues); const formValueSigners = formValues.signers || [];
// Remove the last signer if it's empty.
const recipients = formValueSigners.filter((signer, i) => {
if (i === formValueSigners.length - 1 && signer.email === '') {
return false;
}
return true;
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
...formValues,
signers: recipients,
});
if (validatedFormValues.success) { if (validatedFormValues.success) {
console.log('validatedFormValues', validatedFormValues); console.log('validatedFormValues', validatedFormValues);
setRecipientsDebounced(validatedFormValues.data.signers); setRecipientsDebounced(validatedFormValues.data.signers);
// Todo: Envelopes - Need to save the other data as well if (
// setEnvelope validatedFormValues.data.signingOrder !== envelope.documentMeta.signingOrder ||
validatedFormValues.data.allowDictateNextSigner !==
envelope.documentMeta.allowDictateNextSigner
) {
updateEnvelope({
meta: {
signingOrder: validatedFormValues.data.signingOrder,
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
},
});
}
} }
}, [formValues]); }, [formValues]);
@@ -508,7 +533,7 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent> <CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}> <Form {...form}>
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-gray-50/80 p-4"> <div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( {!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<Checkbox <Checkbox
@@ -876,6 +901,7 @@ export const EnvelopeEditorRecipientForm = () => {
onValueChange={(value) => { onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole); handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}} }}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||

View File

@@ -215,7 +215,6 @@ export const EnvelopeEditorSettingsDialog = ({
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation(); const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
// Todo: Envelopes - Extract into provider.
const envelopeHasBeenSent = const envelopeHasBeenSent =
envelope.type === EnvelopeType.DOCUMENT && envelope.type === EnvelopeType.DOCUMENT &&
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT); envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
@@ -302,8 +301,6 @@ export const EnvelopeEditorSettingsDialog = ({
setActiveTab('general'); setActiveTab('general');
}, [open, form]); }, [open, form]);
// Todo: Envelopes - Show error indicator if error is in different tab.
const selectedTab = tabs.find((tab) => tab.id === activeTab); const selectedTab = tabs.find((tab) => tab.id === activeTab);
if (!selectedTab) { if (!selectedTab) {

View File

@@ -7,12 +7,16 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react'; import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { import {
useCurrentEnvelopeEditor, useCurrentEnvelopeEditor,
useDebounceFunction, useDebounceFunction,
} from '@documenso/lib/client-only/providers/envelope-editor-provider'; } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
@@ -26,9 +30,9 @@ import {
CardTitle, CardTitle,
} from '@documenso/ui/primitives/card'; } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog'; import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form'; import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input'; import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@@ -41,11 +45,13 @@ type LocalFile = {
isError: boolean; isError: boolean;
}; };
export const EnvelopeEditorPageUpload = () => { export const EnvelopeEditorUploadPage = () => {
const team = useCurrentTeam(); const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor(); const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const [localFiles, setLocalFiles] = useState<LocalFile[]>( const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems envelope.envelopeItems
@@ -220,12 +226,56 @@ export const EnvelopeEditorPageUpload = () => {
debouncedUpdateEnvelopeItems(newLocalFilesValue); debouncedUpdateEnvelopeItems(newLocalFilesValue);
}; };
const dropzoneDisabledMessage = useMemo(() => {
if (!canItemsBeModified) {
return msg`Cannot upload items after the document has been sent`;
}
if (organisation.subscription && remaining.documents === 0) {
return msg`Document upload disabled due to unpaid invoices`;
}
if (maximumEnvelopeItemCount <= localFiles.length) {
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localFiles.length, maximumEnvelopeItemCount, remaining.documents]);
const onFileDropRejected = (fileRejections: FileRejection[]) => {
const maxItemsReached = fileRejections.some((fileRejection) =>
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
);
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
return;
}
toast({
title: t`Upload failed`,
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000,
variant: 'destructive',
});
};
return ( return (
<div className="mx-auto max-w-4xl space-y-6 p-8"> <div className="mx-auto max-w-4xl space-y-6 p-8">
<Card backdropBlur={false} className="border"> <Card backdropBlur={false} className="border">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>Documents</CardTitle> <CardTitle>
<CardDescription>Add and configure multiple documents</CardDescription> <Trans>Documents</Trans>
</CardTitle>
<CardDescription>
<Trans>Add and configure multiple documents</Trans>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -233,9 +283,11 @@ export const EnvelopeEditorPageUpload = () => {
onDrop={onFileDrop} onDrop={onFileDrop}
allowMultiple allowMultiple
className="pb-4 pt-6" className="pb-4 pt-6"
disabled={!canItemsBeModified} disabled={dropzoneDisabledMessage !== null}
disabledMessage={msg`Cannot upload items after the document has been sent`} disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`} disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/> />
{/* Uploaded Files List */} {/* Uploaded Files List */}
@@ -256,7 +308,7 @@ export const EnvelopeEditorPageUpload = () => {
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
style={provided.draggableProps.style} style={provided.draggableProps.style}
className={`flex items-center justify-between rounded-lg bg-gray-50 p-3 transition-shadow ${ className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${
snapshot.isDragging ? 'shadow-md' : '' snapshot.isDragging ? 'shadow-md' : ''
}`} }`}
> >
@@ -282,7 +334,7 @@ export const EnvelopeEditorPageUpload = () => {
<p className="text-sm font-medium">{localFile.title}</p> <p className="text-sm font-medium">{localFile.title}</p>
)} )}
<div className="text-xs text-gray-500"> <div className="text-muted-foreground text-xs">
{localFile.isUploading ? ( {localFile.isUploading ? (
<Trans>Uploading</Trans> <Trans>Uploading</Trans>
) : localFile.isError ? ( ) : localFile.isError ? (
@@ -295,7 +347,7 @@ export const EnvelopeEditorPageUpload = () => {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{localFile.isUploading && ( {localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center"> <div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-gray-500" /> <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div> </div>
)} )}
@@ -338,7 +390,7 @@ export const EnvelopeEditorPageUpload = () => {
<div className="flex justify-end"> <div className="flex justify-end">
<Button asChild> <Button asChild>
<Link to={`/t/${team.url}/documents/${envelope.id}/edit?step=addFields`}> <Link to={`${relativePath.editorPath}?step=addFields`}>
<Trans>Add Fields</Trans> <Trans>Add Fields</Trans>
</Link> </Link>
</Button> </Button>

View File

@@ -24,7 +24,6 @@ import {
mapSecondaryIdToDocumentId, mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId, mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope'; } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@@ -32,17 +31,17 @@ import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog'; import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog'; import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog'; import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog'; import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog'; import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog'; import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
import EnvelopeEditorHeader from './envelope-editor-header'; import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPageFields } from './envelope-editor-page-fields'; import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview'; import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview'; type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
@@ -74,10 +73,17 @@ export default function EnvelopeEditor() {
const { t } = useLingui(); const { t } = useLingui();
const navigate = useNavigate(); const navigate = useNavigate();
const team = useCurrentTeam();
const { envelope, isDocument, isTemplate, isAutosaving, flushAutosave } = const {
useCurrentEnvelopeEditor(); envelope,
isDocument,
isTemplate,
isAutosaving,
flushAutosave,
relativePath,
syncEnvelope,
editorFields,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -100,13 +106,10 @@ export default function EnvelopeEditor() {
return 'upload'; return 'upload';
}); });
const documentsPath = formatDocumentsPath(team.url);
const templatesPath = formatTemplatesPath(team.url);
const navigateToStep = (step: EnvelopeEditorStep) => { const navigateToStep = (step: EnvelopeEditorStep) => {
setCurrentStep(step); setCurrentStep(step);
flushAutosave(); void flushAutosave();
if (!isStepLoading && isAutosaving) { if (!isStepLoading && isAutosaving) {
setIsStepLoading(true); setIsStepLoading(true);
@@ -128,6 +131,18 @@ export default function EnvelopeEditor() {
} }
}; };
// Watch the URL params and setStep if the step changes.
useEffect(() => {
const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id;
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (!isAutosaving) { if (!isAutosaving) {
setIsStepLoading(false); setIsStepLoading(false);
@@ -138,20 +153,22 @@ export default function EnvelopeEditor() {
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0]; envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
return ( return (
<div className="h-screen w-screen bg-gray-50"> <div className="dark:bg-background h-screen w-screen bg-gray-50">
<EnvelopeEditorHeader /> <EnvelopeEditorHeader />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex h-[calc(100vh-73px)] w-screen"> <div className="flex h-[calc(100vh-73px)] w-screen">
{/* Left Section - Step Navigation */} {/* Left Section - Step Navigation */}
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4"> <div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
{/* Left section step selector. */} {/* Left section step selector. */}
<div className="px-4"> <div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900"> <h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>} {isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs"> <span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
Step {currentStepData.order}/{envelopeEditorSteps.length} <Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span> </span>
</h3> </h3>
@@ -176,15 +193,17 @@ export default function EnvelopeEditor() {
key={step.id} key={step.id}
className={`cursor-pointer rounded-lg p-3 transition-colors ${ className={`cursor-pointer rounded-lg p-3 transition-colors ${
isActive isActive
? 'border border-green-200 bg-green-50' ? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border border-gray-200 hover:bg-gray-50' : 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`} }`}
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)} onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div <div
className={`rounded border p-2 ${ className={`rounded border p-2 ${
isActive ? 'border-green-200 bg-green-50' : 'border-gray-100 bg-gray-100' isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`} }`}
> >
<Icon <Icon
@@ -194,12 +213,14 @@ export default function EnvelopeEditor() {
<div> <div>
<div <div
className={`text-sm font-medium ${ className={`text-sm font-medium ${
isActive ? 'text-green-900' : 'text-gray-700' isActive
? 'text-green-900 dark:text-green-400'
: 'text-foreground dark:text-muted-foreground'
}`} }`}
> >
{t(step.title)} {t(step.title)}
</div> </div>
<div className="text-xs text-gray-500">{t(step.description)}</div> <div className="text-muted-foreground text-xs">{t(step.description)}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -212,12 +233,25 @@ export default function EnvelopeEditor() {
{/* Quick Actions. */} {/* Quick Actions. */}
<div className="space-y-3 px-4"> <div className="space-y-3 px-4">
<h4 className="text-sm font-semibold text-gray-900"> <h4 className="text-foreground text-sm font-semibold">
<Trans>Quick Actions</Trans> <Trans>Quick Actions</Trans>
</h4> </h4>
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
{isDocument && ( {isDocument && (
<EnvelopeDistributeDialog <EnvelopeDistributeDialog
envelope={envelope} envelope={{
...envelope,
fields: editorFields.localFields,
}}
onDistribute={syncEnvelope}
trigger={ trigger={
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="mr-2 h-4 w-4" />
@@ -239,16 +273,6 @@ export default function EnvelopeEditor() {
/> />
)} )}
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
{/* Todo: Envelopes */}
{/* <Button variant="ghost" size="sm" className="w-full justify-start"> {/* <Button variant="ghost" size="sm" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
Save as Template Save as Template
@@ -283,11 +307,17 @@ export default function EnvelopeEditor() {
} }
/> />
{/* Todo: Allow selecting which document to download and/or the original */} <EnvelopeDownloadDialog
<Button variant="ghost" size="sm" className="w-full justify-start"> envelopeId={envelope.id}
<DownloadCloudIcon className="mr-2 h-4 w-4" /> envelopeStatus={envelope.status}
<Trans>Download PDF</Trans> envelopeItems={envelope.envelopeItems}
</Button> trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
<Button <Button
variant="ghost" variant="ghost"
@@ -309,7 +339,7 @@ export default function EnvelopeEditor() {
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
onDelete={async () => { onDelete={async () => {
await navigate(documentsPath); await navigate(relativePath.documentRootPath);
}} }}
/> />
) : ( ) : (
@@ -318,7 +348,7 @@ export default function EnvelopeEditor() {
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
onDelete={async () => { onDelete={async () => {
await navigate(templatesPath); await navigate(relativePath.templateRootPath);
}} }}
/> />
)} )}
@@ -326,7 +356,7 @@ export default function EnvelopeEditor() {
{/* Footer of left sidebar. */} {/* Footer of left sidebar. */}
<div className="mt-auto px-4"> <div className="mt-auto px-4">
<Button variant="ghost" className="w-full justify-start" asChild> <Button variant="ghost" className="w-full justify-start" asChild>
<Link to={isDocument ? documentsPath : templatesPath}> <Link to={relativePath.basePath}>
<ArrowLeftIcon className="mr-2 h-4 w-4" /> <ArrowLeftIcon className="mr-2 h-4 w-4" />
{isDocument ? ( {isDocument ? (
<Trans>Return to documents</Trans> <Trans>Return to documents</Trans>
@@ -340,13 +370,12 @@ export default function EnvelopeEditor() {
{/* Main Content - Changes based on current step */} {/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
<AnimateGenericFadeInOut key={currentStep}> <AnimateGenericFadeInOut key={currentStep}>
{match({ currentStep, isStepLoading }) {match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />) .with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />) .with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />) .with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />) .with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()} .exhaustive()}
</AnimateGenericFadeInOut> </AnimateGenericFadeInOut>
</div> </div>

View File

@@ -20,16 +20,16 @@ export const EnvelopeItemSelector = ({
}: EnvelopeItemSelectorProps) => { }: EnvelopeItemSelectorProps) => {
return ( return (
<button <button
className={`flex min-w-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${ className={`flex min-w-0 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
isSelected isSelected
? 'border-blue-200 bg-blue-50 text-blue-900' ? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
: 'border-gray-200 bg-gray-50 hover:bg-gray-100' : 'border-border bg-muted/50 hover:bg-muted/70'
}`} }`}
{...buttonProps} {...buttonProps}
> >
<div <div
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${ className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
isSelected ? 'bg-blue-100 text-blue-600' : 'bg-gray-200 text-gray-600' isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
}`} }`}
> >
{number} {number}
@@ -40,7 +40,7 @@ export const EnvelopeItemSelector = ({
</div> </div>
<div <div
className={cn('h-2 w-2 rounded-full', { className={cn('h-2 w-2 rounded-full', {
'bg-blue-500': isSelected, 'bg-green-500': isSelected,
})} })}
></div> ></div>
</button> </button>

View File

@@ -1,41 +1,32 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import Konva from 'konva'; import type Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
export default function EnvelopeGenericPageRenderer() { export default function EnvelopeGenericPageRenderer() {
const pageContext = usePageContext(); const { i18n } = useLingui();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null); const {
const konvaContainer = useRef<HTMLDivElement>(null); stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const stage = useRef<Konva.Stage | null>(null); const { _className, scale } = pageContext;
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
@@ -46,44 +37,6 @@ export default function EnvelopeGenericPageRenderer() {
[fields, pageContext.pageNumber], [fields, pageContext.pageNumber],
); );
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => { const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
@@ -91,6 +44,7 @@ export default function EnvelopeGenericPageRenderer() {
} }
renderField({ renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: field.id.toString(), renderId: field.id.toString(),
@@ -103,8 +57,9 @@ export default function EnvelopeGenericPageRenderer() {
inserted: false, inserted: false,
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
}, },
pageWidth: viewport.width, translations: getClientSideFieldTranslations(i18n),
pageHeight: viewport.height, pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
// color: getRecipientColorKey(field.recipientId), // color: getRecipientColorKey(field.recipientId),
color: 'purple', // Todo color: 'purple', // Todo
editable: false, editable: false,
@@ -113,25 +68,15 @@ export default function EnvelopeGenericPageRenderer() {
}; };
/** /**
* Create the initial Konva page canvas and initialize all fields and interactions. * Initialize the Konva page canvas and all fields and interactions.
*/ */
const createPageCanvas = (container: HTMLDivElement) => { const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); renderFieldOnLayer(field);
} }
pageLayer.current.batchDraw(); currentPageLayer.batchDraw();
}; };
/** /**
@@ -167,14 +112,19 @@ export default function EnvelopeGenericPageRenderer() {
} }
return ( return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}> <div
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div> className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
width={viewport.width} height={scaledViewport.height}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@@ -1,17 +1,29 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerForm() { export default function EnvelopeSignerForm() {
const { fullName, signature, setFullName, setSignature, envelope, recipientFields } = const {
useRequiredEnvelopeSigningContext(); fullName,
signature,
setFullName,
setSignature,
envelope,
recipientFields,
recipient,
assistantFields,
assistantRecipients,
selectedAssistantRecipient,
setSelectedAssistantRecipientId,
} = useRequiredEnvelopeSigningContext();
const hasSignatureField = useMemo(() => { const hasSignatureField = useMemo(() => {
return recipientFields.some((field) => field.type === FieldType.SIGNATURE); return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
@@ -19,6 +31,63 @@ export default function EnvelopeSignerForm() {
const isSubmitting = false; const isSubmitting = false;
if (recipient.role === RecipientRole.VIEWER) {
return null;
}
if (recipient.role === RecipientRole.ASSISTANT) {
return (
<fieldset className="dark:bg-background border-border rounded-2xl sm:border sm:p-3">
<RadioGroup
className="gap-0 space-y-2 shadow-none sm:space-y-3"
value={selectedAssistantRecipient?.id?.toString()}
onValueChange={(value) => {
setSelectedAssistantRecipientId(Number(value));
}}
>
{assistantRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={r.id}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={r.id.toString()}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label className="inline-flex items-start" htmlFor={r.id.toString()}>
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
<Trans>(You)</Trans>
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
<Plural
value={assistantFields.filter((field) => field.recipientId === r.id).length}
one="# field"
other="# fields"
/>
</div>
</div>
</div>
))}
</RadioGroup>
</fieldset>
);
}
return ( return (
<fieldset disabled={isSubmitting} className="flex flex-1 flex-col gap-4"> <fieldset disabled={isSubmitting} className="flex flex-1 flex-col gap-4">
<div className="flex flex-1 flex-col gap-y-4"> <div className="flex flex-1 flex-col gap-y-4">

View File

@@ -1,131 +1,139 @@
import { Plural, Trans, useLingui } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { Link, useNavigate } from 'react-router'; import { EnvelopeType, RecipientRole } from '@prisma/client';
import { BanIcon, DownloadCloudIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog'; import { BrandingLogoIcon } from '../branding-logo-icon';
import { DocumentSigningRejectDialog } from '../document-signing/document-signing-reject-dialog';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
import { EnvelopeSignerCompleteDialog } from './envelope-signing-complete-dialog';
export const EnvelopeSignerHeader = () => { export const EnvelopeSignerHeader = () => {
const { t } = useLingui(); const { envelopeData, envelope, recipientFieldsRemaining, recipient } =
const navigate = useNavigate();
const analytics = useAnalytics();
const { envelope, setShowPendingFieldTooltip, recipientFieldsRemaining, recipient } =
useRequiredEnvelopeSigningContext(); useRequiredEnvelopeSigningContext();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const {
mutateAsync: completeDocument,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const handleOnNextFieldClick = () => {
const nextField = recipientFieldsRemaining[0];
if (!nextField) {
setShowPendingFieldTooltip(false);
return;
}
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
setCurrentEnvelopeItem(nextField.envelopeItemId);
}
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setShowPendingFieldTooltip(true);
};
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => {
const payload = {
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
authOptions: accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocument(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
return ( return (
<nav className="w-full border-b border-gray-200 bg-white px-6 py-3"> <nav className="bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
<div className="flex items-center justify-between"> {/* Left side - Logo and title */}
<div className="flex items-center space-x-4"> <div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
<Link to="/"> <Link to="/" className="flex-shrink-0">
<BrandingLogo className="h-6 w-auto" /> {envelopeData.settings.brandingEnabled && envelopeData.settings.brandingLogo ? (
</Link> <img
<Separator orientation="vertical" className="h-6" /> src={`/api/branding/logo/team/${envelope.teamId}`}
alt={`${envelope.team.name}'s Logo`}
<div className="flex items-center space-x-2"> className="h-6 w-auto"
<h1 className="whitespace-nowrap text-sm font-medium text-gray-600">
{envelope.title}
</h1>
<Badge variant="secondary">
<Trans>Approver</Trans>
</Badge>
</div>
</div>
<div className="flex items-center space-x-2">
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
<Plural
one="1 Field Remaining"
other="# Fields Remaining"
value={recipientFieldsRemaining.length}
/> />
</p> ) : (
<>
<BrandingLogo className="hidden h-6 w-auto md:block" />
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
</>
)}
</Link>
<DocumentSigningCompleteDialog <h1
isSubmitting={isPending} title={envelope.title}
onSignatureComplete={handleOnCompleteClick} className="text-foreground min-w-0 truncate text-base font-semibold md:hidden"
documentTitle={envelope.title} >
fields={recipientFieldsRemaining} {envelope.title}
fieldsValidated={handleOnNextFieldClick} </h1>
recipient={recipient}
// Todo: Envelopes <Separator orientation="vertical" className="hidden h-6 md:block" />
allowDictateNextSigner={envelope.documentMeta.allowDictateNextSigner}
// defaultNextSigner={ <div className="hidden items-center space-x-2 md:flex">
// nextRecipient <h1 className="text-foreground whitespace-nowrap text-sm font-medium">
// ? { name: nextRecipient.name, email: nextRecipient.email } {envelope.title}
// : undefined </h1>
// }
// Todo: Envelopes - use <Badge>
// buttonSize="sm" {match(recipient.role)
/> .with(RecipientRole.VIEWER, () => <Trans>Viewer</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Signer</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approver</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assistant</Trans>)
.otherwise(() => null)}
</Badge>
</div> </div>
</div> </div>
{/* Right side - Desktop content */}
<div className="hidden items-center space-x-2 md:flex">
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
<Plural
one="1 Field Remaining"
other="# Fields Remaining"
value={recipientFieldsRemaining.length}
/>
</p>
<EnvelopeSignerCompleteDialog />
</div>
{/* Mobile Actions button */}
<div className="flex-shrink-0 md:hidden">
<MobileDropdownMenu />
</div>
</nav> </nav>
); );
}; };
const MobileDropdownMenu = () => {
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Trans>Actions</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</div>
</DropdownMenuItem>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,22 +1,25 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { type Field, FieldType } from '@prisma/client'; import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
import Konva from 'konva'; import type Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { ZFullFieldSchema } from '@documenso/lib/types/field'; import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items'; import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field'; import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
import { handleEmailFieldClick } from '~/utils/field-signing/email-field'; import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field'; import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field';
@@ -28,24 +31,13 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerPageRenderer() { export default function EnvelopeSignerPageRenderer() {
const pageContext = usePageContext(); const { i18n } = useLingui();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { const {
envelopeData, envelopeData,
recipient,
recipientFields, recipientFields,
recipientFieldsRemaining, recipientFieldsRemaining,
showPendingFieldTooltip, showPendingFieldTooltip,
@@ -56,71 +48,39 @@ export default function EnvelopeSignerPageRenderer() {
setFullName, setFullName,
signature, signature,
setSignature, setSignature,
selectedAssistantRecipientFields,
selectedAssistantRecipient,
isDirectTemplate,
} = useRequiredEnvelopeSigningContext(); } = useRequiredEnvelopeSigningContext();
console.log({ fullName }); const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const { envelope } = envelopeData; const { envelope } = envelopeData;
const canvasElement = useRef<HTMLCanvasElement>(null); const localPageFields = useMemo(() => {
const konvaContainer = useRef<HTMLDivElement>(null); let fieldsToRender = recipientFields;
const stage = useRef<Konva.Stage | null>(null); if (recipient.role === RecipientRole.ASSISTANT) {
const pageLayer = useRef<Layer | null>(null); fieldsToRender = selectedAssistantRecipientFields;
}
const viewport = useMemo( return fieldsToRender.filter(
() => page.getViewport({ scale, rotation: rotate }), (field) =>
[page, rotate, scale], field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
); );
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
const localPageFields = useMemo( const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
() =>
recipientFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[recipientFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (unparsedField: Field) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
return; return;
@@ -137,6 +97,7 @@ export default function EnvelopeSignerPageRenderer() {
} }
const { fieldGroup } = renderField({ const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: fieldToRender.id.toString(), renderId: fieldToRender.id.toString(),
@@ -145,9 +106,11 @@ export default function EnvelopeSignerPageRenderer() {
height: Number(fieldToRender.height), height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX), positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY), positionY: Number(fieldToRender.positionY),
signature: unparsedField.signature,
}, },
pageWidth: viewport.width, translations: getClientSideFieldTranslations(i18n),
pageHeight: viewport.height, pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color, color,
mode: 'sign', mode: 'sign',
}); });
@@ -158,19 +121,35 @@ export default function EnvelopeSignerPageRenderer() {
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect(); const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
const foundField = recipientFields.find((f) => f.id === unparsedField.id); const foundField = localPageFields.find((f) => f.id === unparsedField.id);
const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group'); const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group');
if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) { if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) {
return; return;
} }
const loadingSpinnerGroup = createSpinner({ let localEmail: string | null = email;
fieldWidth, let localFullName: string | null = fullName;
fieldHeight, let placeholderEmail: string | null = null;
});
fieldGroup.add(loadingSpinnerGroup); if (recipient.role === RecipientRole.ASSISTANT) {
localEmail = selectedAssistantRecipient?.email || null;
localFullName = selectedAssistantRecipient?.name || null;
}
// Allows us let the user set a different email than their current logged in email.
if (isDirectTemplate) {
placeholderEmail = sessionData?.user?.email || email || recipient.email;
if (!placeholderEmail || placeholderEmail === DIRECT_TEMPLATE_RECIPIENT_EMAIL) {
placeholderEmail = null;
}
}
const loadingSpinnerGroup = createSpinner({
fieldWidth: fieldWidth / scale,
fieldHeight: fieldHeight / scale,
});
const parsedFoundField = ZFullFieldSchema.parse(foundField); const parsedFoundField = ZFullFieldSchema.parse(foundField);
@@ -179,34 +158,39 @@ export default function EnvelopeSignerPageRenderer() {
* CHECKBOX FIELD. * CHECKBOX FIELD.
*/ */
.with({ type: FieldType.CHECKBOX }, (field) => { .with({ type: FieldType.CHECKBOX }, (field) => {
const { fieldMeta } = field; const clickedCheckboxIndex = Number(target.getAttr('internalCheckboxIndex'));
const { values } = fieldMeta; if (Number.isNaN(clickedCheckboxIndex)) {
return;
}
const checkedValues = (values || []) handleCheckboxFieldClick({ field, clickedCheckboxIndex })
.map((v) => ({ .then(async (payload) => {
...v, if (payload) {
checked: v.id === target.getAttr('internalCheckboxId') ? !v.checked : v.checked, fieldGroup.add(loadingSpinnerGroup);
})) await signField(field.id, payload);
.filter((v) => v.checked); }
})
void signField(field.id, { .finally(() => {
type: FieldType.CHECKBOX, loadingSpinnerGroup.destroy();
value: checkedValues.map((v) => v.id), });
}).finally(() => {
loadingSpinnerGroup.destroy();
});
}) })
/** /**
* RADIO FIELD. * RADIO FIELD.
*/ */
.with({ type: FieldType.RADIO }, (field) => { .with({ type: FieldType.RADIO }, (field) => {
const { fieldMeta } = foundField; const selectedRadioIndex = Number(target.getAttr('internalRadioIndex'));
const fieldCustomText = Number(field.customText);
const checkedValue = target.getAttr('internalRadioValue'); if (Number.isNaN(selectedRadioIndex)) {
return;
}
fieldGroup.add(loadingSpinnerGroup);
// Uncheck the value if it's already pressed. // Uncheck the value if it's already pressed.
const value = field.inserted && checkedValue === field.customText ? null : checkedValue; const value =
field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex;
void signField(field.id, { void signField(field.id, {
type: FieldType.RADIO, type: FieldType.RADIO,
@@ -222,6 +206,7 @@ export default function EnvelopeSignerPageRenderer() {
handleNumberFieldClick({ field, number: null }) handleNumberFieldClick({ field, number: null })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
}) })
@@ -236,6 +221,7 @@ export default function EnvelopeSignerPageRenderer() {
handleTextFieldClick({ field, text: null }) handleTextFieldClick({ field, text: null })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
}) })
@@ -247,9 +233,10 @@ export default function EnvelopeSignerPageRenderer() {
* EMAIL FIELD. * EMAIL FIELD.
*/ */
.with({ type: FieldType.EMAIL }, (field) => { .with({ type: FieldType.EMAIL }, (field) => {
handleEmailFieldClick({ field, email }) handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); // Todo: Envelopes - Handle errors await signField(field.id, payload); // Todo: Envelopes - Handle errors
} }
@@ -265,11 +252,12 @@ export default function EnvelopeSignerPageRenderer() {
* INITIALS FIELD. * INITIALS FIELD.
*/ */
.with({ type: FieldType.INITIALS }, (field) => { .with({ type: FieldType.INITIALS }, (field) => {
const initials = fullName ? extractInitials(fullName) : null; const initials = localFullName ? extractInitials(localFullName) : null;
handleInitialsFieldClick({ field, initials }) handleInitialsFieldClick({ field, initials })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
}) })
@@ -281,9 +269,10 @@ export default function EnvelopeSignerPageRenderer() {
* NAME FIELD. * NAME FIELD.
*/ */
.with({ type: FieldType.NAME }, (field) => { .with({ type: FieldType.NAME }, (field) => {
handleNameFieldClick({ field, name: fullName }) handleNameFieldClick({ field, name: localFullName })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
@@ -302,6 +291,7 @@ export default function EnvelopeSignerPageRenderer() {
handleDropdownFieldClick({ field, text: null }) handleDropdownFieldClick({ field, text: null })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
@@ -315,6 +305,8 @@ export default function EnvelopeSignerPageRenderer() {
* DATE FIELD. * DATE FIELD.
*/ */
.with({ type: FieldType.DATE }, (field) => { .with({ type: FieldType.DATE }, (field) => {
fieldGroup.add(loadingSpinnerGroup);
void signField(field.id, { void signField(field.id, {
type: FieldType.DATE, type: FieldType.DATE,
value: !field.inserted, value: !field.inserted,
@@ -336,6 +328,7 @@ export default function EnvelopeSignerPageRenderer() {
}) })
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload);
} }
@@ -348,38 +341,22 @@ export default function EnvelopeSignerPageRenderer() {
}); });
}) })
.exhaustive(); .exhaustive();
console.log('Field clicked');
}; };
fieldGroup.off('click'); fieldGroup.off('pointerdown');
fieldGroup.on('click', handleFieldGroupClick); fieldGroup.on('pointerdown', handleFieldGroupClick);
}; };
/** /**
* Create the initial Konva page canvas and initialize all fields and interactions. * Initialize the Konva page canvas and all fields and interactions.
*/ */
const createPageCanvas = (container: HTMLDivElement) => { const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
console.log({
localPageFields,
});
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
} }
pageLayer.current.batchDraw(); currentPageLayer.batchDraw();
}; };
/** /**
@@ -392,25 +369,61 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas'); console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field); renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
}); });
pageLayer.current.batchDraw(); pageLayer.current.batchDraw();
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]); }, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
/**
* Rerender the whole page if the selected assistant recipient changes.
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
return;
}
// Rerender the whole page.
pageLayer.current.destroyChildren();
localPageFields.forEach((field) => {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
pageLayer.current.batchDraw();
}, [selectedAssistantRecipient]);
if (!currentEnvelopeItem) { if (!currentEnvelopeItem) {
return null; return null;
} }
return ( return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}> <div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
<EnvelopeFieldToolTip
key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]}
color="warning"
>
<Trans>Click to insert field</Trans>
</EnvelopeFieldToolTip>
)}
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
width={viewport.width} height={scaledViewport.height}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@@ -0,0 +1,182 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { useNavigate, useSearchParams } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export const EnvelopeSignerCompleteDialog = () => {
const navigate = useNavigate();
const analytics = useAnalytics();
const { toast } = useToast();
const { t } = useLingui();
const [searchParams] = useSearchParams();
const {
isDirectTemplate,
envelope,
setShowPendingFieldTooltip,
recipientFieldsRemaining,
recipient,
nextRecipient,
email,
fullName,
} = useRequiredEnvelopeSigningContext();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { mutateAsync: completeDocument, isPending } =
trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: createDocumentFromDirectTemplate } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
const handleOnNextFieldClick = () => {
const nextField = recipientFieldsRemaining[0];
if (!nextField) {
setShowPendingFieldTooltip(false);
return;
}
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
setCurrentEnvelopeItem(nextField.envelopeItemId);
}
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setShowPendingFieldTooltip(true);
};
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => {
const payload = {
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
authOptions: accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocument(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
/**
* Direct template completion flow.
*/
const handleDirectTemplateCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
const { token } = await createDocumentFromDirectTemplate({
directTemplateToken: recipient.token, // The direct template token is inserted into the recipient token for ease of use.
directTemplateExternalId,
directRecipientName: recipientDetails?.name || fullName,
directRecipientEmail: recipientDetails?.email || email,
templateUpdatedAt: envelope.updatedAt,
signedFieldValues: recipient.fields.map((field) => {
let value = field.customText;
let isBase64 = false;
if (field.type === FieldType.SIGNATURE && field.signature) {
value = field.signature.signatureImageAsBase64 || field.signature.typedSignature || '';
isBase64 = isBase64Image(value);
}
return {
token: '',
fieldId: field.id,
value,
isBase64,
};
}),
});
const redirectUrl = envelope.documentMeta.redirectUrl;
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${token}/complete`);
}
} catch (err) {
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
throw err;
}
};
const directTemplatePayload = useMemo(() => {
if (!isDirectTemplate) {
return;
}
return {
name: fullName,
email: email,
};
}, [email, fullName, isDirectTemplate]);
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
directTemplatePayload={directTemplatePayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}
documentTitle={envelope.title}
fields={recipientFieldsRemaining}
fieldsValidated={handleOnNextFieldClick}
recipient={recipient}
allowDictateNextSigner={Boolean(
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
)}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
buttonSize="sm"
position="center"
/>
);
};

View File

@@ -5,6 +5,7 @@ import { FolderType } from '@prisma/client';
import { FolderIcon, HomeIcon } from 'lucide-react'; import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@@ -19,6 +20,8 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card'; import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
export type FolderGridProps = { export type FolderGridProps = {
type: FolderType; type: FolderType;
parentId: string | null; parentId: string | null;
@@ -26,6 +29,7 @@ export type FolderGridProps = {
export const FolderGrid = ({ type, parentId }: FolderGridProps) => { export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const team = useCurrentTeam(); const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const [isMovingFolder, setIsMovingFolder] = useState(false); const [isMovingFolder, setIsMovingFolder] = useState(false);
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null); const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
@@ -94,8 +98,9 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div> </div>
<div className="flex gap-4 sm:flex-row sm:justify-end"> <div className="flex gap-4 sm:flex-row sm:justify-end">
{/* Todo: Envelopes - Feature flag */} {organisation.organisationClaim.flags.allowEnvelopes && (
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */} <EnvelopeUploadButton type={type} folderId={parentId || undefined} />
)}
{type === FolderType.DOCUMENT ? ( {type === FolderType.DOCUMENT ? (
<DocumentUploadButton /> <DocumentUploadButton />

View File

@@ -10,9 +10,9 @@ import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -40,13 +40,17 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
try { try {
setIsLoading(true); setIsLoading(true);
const documentData = await putPdfFile(file); const payload = {
const { legacyTemplateId: id } = await createTemplate({
title: file.name, title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
}); } satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({ toast({
title: _(msg`Template uploaded`), title: _(msg`Template uploaded`),

View File

@@ -17,6 +17,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
export type DocumentsTableActionButtonProps = { export type DocumentsTableActionButtonProps = {
row: TDocumentRow; row: TDocumentRow;
}; };
@@ -88,6 +90,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isComplete, isComplete,
isSigned, isSigned,
isCurrentTeamDocument, isCurrentTeamDocument,
internalVersion: row.internalVersion,
}) })
.with( .with(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
@@ -131,6 +134,19 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<Trans>View</Trans> <Trans>View</Trans>
</Button> </Button>
)) ))
.with({ isComplete: true, internalVersion: 2 }, () => (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
trigger={
<Button className="w-32">
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
}
/>
))
.with({ isComplete: true }, () => ( .with({ isComplete: true }, () => (
<Button className="w-32" onClick={onDownloadClick}> <Button className="w-32" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" /> <Download className="-ml-1 mr-2 inline h-4 w-4" />

View File

@@ -42,6 +42,8 @@ import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialo
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
export type DocumentsTableActionDropdownProps = { export type DocumentsTableActionDropdownProps = {
row: TDocumentRow; row: TDocumentRow;
onMoveDocument?: () => void; onMoveDocument?: () => void;
@@ -176,15 +178,33 @@ export const DocumentsTableActionDropdown = ({
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}> {row.internalVersion === 2 ? (
<Download className="mr-2 h-4 w-4" /> <EnvelopeDownloadDialog
<Trans>Download</Trans> envelopeId={row.envelopeId}
</DropdownMenuItem> envelopeStatus={row.status}
token={recipient?.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
</DropdownMenuItem>
}
/>
) : (
<>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDownloadOriginalClick}> <DropdownMenuItem onClick={onDownloadOriginalClick}>
<FileDown className="mr-2 h-4 w-4" /> <FileDown className="mr-2 h-4 w-4" />
<Trans>Download Original</Trans> <Trans>Download Original</Trans>
</DropdownMenuItem> </DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}> <DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />

View File

@@ -159,6 +159,7 @@ export const TemplatesTable = ({
return ( return (
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<TemplateUseDialog <TemplateUseDialog
envelopeId={row.original.envelopeId}
templateId={row.original.id} templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder} templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod} documentDistributionMethod={row.original.templateMeta?.distributionMethod}

View File

@@ -404,6 +404,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
claims: { claims: {
teamCount: organisation.organisationClaim.teamCount, teamCount: organisation.organisationClaim.teamCount,
memberCount: organisation.organisationClaim.memberCount, memberCount: organisation.organisationClaim.memberCount,
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
flags: organisation.organisationClaim.flags, flags: organisation.organisationClaim.flags,
}, },
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '', originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
@@ -561,6 +562,30 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
)} )}
/> />
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div> <div>
<FormLabel> <FormLabel>
<Trans>Feature Flags</Trans> <Trans>Feature Flags</Trans>

View File

@@ -5,7 +5,10 @@ import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client'; import { SubscriptionStatus } from '@prisma/client';
import { Link, Outlet } from 'react-router'; import { Link, Outlet } from 'react-router';
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants'; import {
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
PAID_PLAN_LIMITS,
} from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client'; import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { TrpcProvider } from '@documenso/trpc/react'; import { TrpcProvider } from '@documenso/trpc/react';
@@ -38,12 +41,14 @@ export default function Layout() {
recipients: 0, recipients: 0,
directTemplates: 0, directTemplates: 0,
}, },
maximumEnvelopeItemCount: 0,
}; };
} }
return { return {
quota: PAID_PLAN_LIMITS, quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS, remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
}; };
}, [organisation?.subscription]); }, [organisation?.subscription]);

View File

@@ -15,6 +15,7 @@ import {
mapFieldsWithRecipients, mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields'; } from '@documenso/ui/components/document/document-read-only-fields';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -87,6 +88,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
const documentRootPath = formatDocumentsPath(team.url); const documentRootPath = formatDocumentsPath(team.url);
const isMultiEnvelopeItem = envelope.envelopeItems.length > 1 && envelope.internalVersion === 2;
return ( return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
{envelope.status === DocumentStatus.PENDING && ( {envelope.status === DocumentStatus.PENDING && (
@@ -140,40 +143,51 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
</div> </div>
<div className="mt-6 grid w-full grid-cols-12 gap-8"> <div className="mt-6 grid w-full grid-cols-12 gap-8">
<Card {envelope.internalVersion === 2 ? (
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7" <div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
gradient <EnvelopeRenderProvider
> envelope={envelope}
<CardContent className="p-2"> fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
{envelope.internalVersion === 2 ? ( >
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}> {isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" /> <EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
)}
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} /> <Card className="rounded-xl before:rounded-xl" gradient>
</EnvelopeRenderProvider> <CardContent className="p-2">
) : ( <PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<> </CardContent>
{envelope.status !== DocumentStatus.COMPLETED && ( </Card>
<DocumentReadOnlyFields </EnvelopeRenderProvider>
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)} </div>
documentMeta={envelope.documentMeta || undefined} ) : (
showRecipientTooltip={true} <Card
showRecipientColors={true} className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
recipientIds={envelope.recipients.map((recipient) => recipient.id)} gradient
/> >
)} <CardContent className="p-2">
{envelope.status !== DocumentStatus.COMPLETED && (
<PDFViewer <DocumentReadOnlyFields
document={envelope} fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
key={envelope.envelopeItems[0].id} documentMeta={envelope.documentMeta || undefined}
documentData={envelope.envelopeItems[0].documentData} showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
/> />
</> )}
)}
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <PDFViewer
document={envelope}
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
/>
</CardContent>
</Card>
)}
<div
className={cn('col-span-12 lg:col-span-6 xl:col-span-5', isMultiEnvelopeItem && 'mt-20')}
>
<div className="space-y-6"> <div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6"> <section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4"> <div className="flex flex-row items-center justify-between px-4">

View File

@@ -9,6 +9,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger'; import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams'; import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { DocumentEditForm } from '~/components/general/document/document-edit-form'; import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover'; import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
@@ -122,11 +123,13 @@ export default function DocumentEditPage() {
</div> </div>
</div> </div>
{document.useLegacyFieldInsertion && ( <div className="flex items-center gap-x-4">
<div> <DocumentAttachmentsPopover envelopeId={document.envelopeId} />
{document.useLegacyFieldInsertion && (
<LegacyFieldWarningPopover type="document" documentId={document.id} /> <LegacyFieldWarningPopover type="document" documentId={document.id} />
</div> )}
)} </div>
</div> </div>
<DocumentEditForm <DocumentEditForm

View File

@@ -11,6 +11,7 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@@ -108,6 +109,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
} }
: undefined; : undefined;
const isMultiEnvelopeItem = envelope.envelopeItems.length > 1 && envelope.internalVersion === 2;
return ( return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80"> <Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
@@ -163,39 +166,47 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
</div> </div>
<div className="mt-6 grid w-full grid-cols-12 gap-8"> <div className="mt-6 grid w-full grid-cols-12 gap-8">
<Card {envelope.internalVersion === 2 ? (
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7" <div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
gradient <EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
> {isMultiEnvelopeItem && (
<CardContent className="p-2">
{envelope.internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" /> <EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
)}
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} /> <Card className="rounded-xl before:rounded-xl" gradient>
</EnvelopeRenderProvider> <CardContent className="p-2">
) : ( <PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<> </CardContent>
<DocumentReadOnlyFields </Card>
fields={readOnlyFields} </EnvelopeRenderProvider>
showFieldStatus={false} </div>
showRecipientTooltip={true} ) : (
showRecipientColors={true} <Card
recipientIds={envelope.recipients.map((recipient) => recipient.id)} className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
documentMeta={mockedDocumentMeta} gradient
/> >
<CardContent className="p-2">
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta}
/>
<PDFViewer <PDFViewer
document={envelope} document={envelope}
key={envelope.envelopeItems[0].id} key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData} documentData={envelope.envelopeItems[0].documentData}
/> />
</> </CardContent>
)} </Card>
</CardContent> )}
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div
className={cn('col-span-12 lg:col-span-6 xl:col-span-5', isMultiEnvelopeItem && 'mt-20')}
>
<div className="space-y-6"> <div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6"> <section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4"> <div className="flex flex-row items-center justify-between px-4">
@@ -223,6 +234,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<div className="mt-4 border-t px-4 pt-4"> <div className="mt-4 border-t px-4 pt-4">
<TemplateUseDialog <TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)} templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder} templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients} recipients={envelope.recipients}

View File

@@ -8,6 +8,7 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog'; import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover'; import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge'; import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form'; import { TemplateEditForm } from '~/components/general/template/template-edit-form';
@@ -87,6 +88,8 @@ export default function TemplateEditPage() {
</div> </div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end"> <div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<DocumentAttachmentsPopover envelopeId={template.envelopeId} />
<TemplateDirectLinkDialog <TemplateDirectLinkDialog
templateId={template.id} templateId={template.id}
directLink={template.directLink} directLink={template.directLink}

View File

@@ -22,7 +22,9 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
// Hide the header for signing routes. // Hide the header for signing routes.
const hideHeader = matches.some( const hideHeader = matches.some(
(match) => match?.id === 'routes/_recipient+/sign.$token+/_index', (match) =>
match?.id === 'routes/_recipient+/sign.$token+/_index' ||
match?.id === 'routes/_recipient+/d.$token+/_index',
); );
return ( return (

View File

@@ -4,20 +4,29 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page'; import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page'; import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider'; import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider'; import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index'; import type { Route } from './+types/_index';
export async function loader({ params, request }: Route.LoaderArgs) { const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
const session = await getOptionalSession(request); const session = await getOptionalSession(request);
const { token } = params; const { token } = params;
@@ -55,27 +64,111 @@ export async function loader({ params, request }: Route.LoaderArgs) {
); );
if (!isAccessAuthValid) { if (!isAccessAuthValid) {
return superLoaderJson({ return {
isAccessAuthValid: false as const, isAccessAuthValid: false as const,
}); };
} }
return superLoaderJson({ return {
isAccessAuthValid: true, isAccessAuthValid: true,
template: { template: {
...template, ...template,
folder: null, folder: null,
}, },
directTemplateRecipient, directTemplateRecipient,
} as const;
};
const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
const session = await getOptionalSession(request);
const { token } = params;
if (!token) {
throw redirect('/');
}
return await getEnvelopeForDirectTemplateSigning({
token,
userId: session?.user?.id,
})
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
envelopeForSigning,
} as const;
})
.catch(async (e) => {
const error = AppError.parseError(e);
if (error.code === AppErrorCode.UNAUTHORIZED) {
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
return {
isDocumentAccessValid: false,
...requiredAccessData,
} as const;
}
throw new Response('Not Found', { status: 404 });
});
};
export async function loader(loaderArgs: Route.LoaderArgs) {
const { token } = loaderArgs.params;
if (!token) {
throw redirect('/');
}
const directEnvelope = await prisma.envelope.findFirst({
where: {
directLink: {
enabled: true,
token,
},
},
select: {
internalVersion: true,
},
});
if (!directEnvelope) {
throw new Response('Not Found', { status: 404 });
}
if (directEnvelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({
version: 2,
payload: payloadV2,
} as const);
}
const payloadV1 = await handleV1Loader(loaderArgs);
return superLoaderJson({
version: 1,
payload: payloadV1,
} as const); } as const);
} }
export default function DirectTemplatePage() { export default function DirectTemplatePage() {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const data = useSuperLoaderData<typeof loader>(); const data = useSuperLoaderData<typeof loader>();
if (data.version === 2) {
return <DirectSigningPageV2 data={data.payload} />;
}
return <DirectSigningPageV1 data={data.payload} />;
}
const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
// Should not be possible for directLink to be null. // Should not be possible for directLink to be null.
if (!data.isAccessAuthValid) { if (!data.isAccessAuthValid) {
return <DirectTemplateAuthPageView />; return <DirectTemplateAuthPageView />;
@@ -97,28 +190,68 @@ export default function DirectTemplatePage() {
recipient={directTemplateRecipient} recipient={directTemplateRecipient}
user={user} user={user}
> >
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <>
<h1 {sessionData?.user && <AuthenticatedHeader />}
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<UsersIcon className="h-4 w-4" /> <h1
<p className="text-muted-foreground/80"> className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
<Plural value={template.recipients.length} one="# recipient" other="# recipients" /> title={template.title}
</p> >
{template.title}
</h1>
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
<UsersIcon className="h-4 w-4" />
<p className="text-muted-foreground/80">
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
</p>
</div>
<DirectTemplatePageView
directTemplateRecipient={directTemplateRecipient}
directTemplateToken={template.directLink.token}
template={template}
/>
</div> </div>
</>
<DirectTemplatePageView
directTemplateRecipient={directTemplateRecipient}
directTemplateToken={template.directLink.token}
template={template}
/>
</div>
</DocumentSigningAuthProvider> </DocumentSigningAuthProvider>
</DocumentSigningProvider> </DocumentSigningProvider>
); );
} };
const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loader>> }) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
}
const { envelope, recipient } = data.envelopeForSigning;
return (
<EnvelopeSigningProvider
envelopeData={data.envelopeForSigning}
email={''} // Doing this allows us to let users change the email if they want to.
fullName={user?.name}
signature={user?.signature}
>
<DocumentSigningAuthProvider
documentAuthOptions={envelope.authOptions}
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider envelope={envelope}>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>
</EnvelopeSigningProvider>
);
};

View File

@@ -122,6 +122,7 @@ export default function EmbedDirectTemplatePage() {
<DocumentSigningRecipientProvider recipient={recipient}> <DocumentSigningRecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage <EmbedDirectTemplateClientPage
token={token} token={token}
envelopeId={template.envelopeId}
updatedAt={template.updatedAt} updatedAt={template.updatedAt}
documentData={template.templateDocumentData} documentData={template.templateDocumentData}
recipient={recipient} recipient={recipient}

View File

@@ -164,6 +164,7 @@ export default function EmbedSignDocumentPage() {
<EmbedSignDocumentClientPage <EmbedSignDocumentClientPage
token={token} token={token}
documentId={document.id} documentId={document.id}
envelopeId={document.envelopeId}
documentData={document.documentData} documentData={document.documentData}
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}

View File

@@ -0,0 +1,74 @@
import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TFieldCheckbox } from '@documenso/lib/types/field';
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { SignFieldCheckboxDialog } from '~/components/dialogs/sign-field-checkbox-dialog';
type HandleCheckboxFieldClickOptions = {
field: TFieldCheckbox;
clickedCheckboxIndex: number;
};
export const handleCheckboxFieldClick = async (
options: HandleCheckboxFieldClickOptions,
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.CHECKBOX }> | null> => {
const { field, clickedCheckboxIndex } = options;
if (field.type !== FieldType.CHECKBOX) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid field type',
});
}
const { values = [], validationRule, validationLength } = field.fieldMeta;
const { customText } = field;
const currentCheckedIndices: number[] = customText ? parseCheckboxCustomText(customText) : [];
const newValues = values.map((_value, i) => {
let isChecked = currentCheckedIndices.includes(i);
if (i === clickedCheckboxIndex) {
isChecked = !isChecked;
}
return {
index: i,
isChecked,
};
});
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index);
if (validationRule && validationLength) {
const checkboxValidationRule = checkboxValidationSigns.find(
(sign) => sign.label === validationRule,
);
if (!checkboxValidationRule) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
checkedValues = await SignFieldCheckboxDialog.call({
fieldMeta: field.fieldMeta,
validationRule: checkboxValidationRule.value,
validationLength,
preselectedIndices: currentCheckedIndices,
});
}
if (!checkedValues) {
return null;
}
return {
type: FieldType.CHECKBOX,
value: checkedValues,
};
};

View File

@@ -9,12 +9,13 @@ import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dial
type HandleEmailFieldClickOptions = { type HandleEmailFieldClickOptions = {
field: TFieldEmail; field: TFieldEmail;
email: string | null; email: string | null;
placeholderEmail: string | null;
}; };
export const handleEmailFieldClick = async ( export const handleEmailFieldClick = async (
options: HandleEmailFieldClickOptions, options: HandleEmailFieldClickOptions,
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.EMAIL }> | null> => { ): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.EMAIL }> | null> => {
const { field, email } = options; const { field, email, placeholderEmail } = options;
if (field.type !== FieldType.EMAIL) { if (field.type !== FieldType.EMAIL) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
@@ -32,7 +33,9 @@ export const handleEmailFieldClick = async (
let emailToInsert = email; let emailToInsert = email;
if (!emailToInsert) { if (!emailToInsert) {
emailToInsert = await SignFieldEmailDialog.call({}); emailToInsert = await SignFieldEmailDialog.call({
placeholderEmail,
});
} }
if (!emailToInsert) { if (!emailToInsert) {

View File

@@ -30,7 +30,6 @@ export const handleSignatureFieldClick = async (
return { return {
type: FieldType.SIGNATURE, type: FieldType.SIGNATURE,
value: null, value: null,
isBase64: false,
}; };
} }
@@ -51,6 +50,5 @@ export const handleSignatureFieldClick = async (
return { return {
type: FieldType.SIGNATURE, type: FieldType.SIGNATURE,
value: signatureToInsert, value: signatureToInsert,
isBase64: signatureToInsert.startsWith('data:image'),
}; };
}; };

View File

@@ -14,7 +14,7 @@
"with:env": "dotenv -e ../../.env -e ../../.env.local --" "with:env": "dotenv -e ../../.env -e ../../.env.local --"
}, },
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.3.2", "@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*", "@documenso/api": "*",
"@documenso/assets": "*", "@documenso/assets": "*",
"@documenso/auth": "*", "@documenso/auth": "*",
@@ -103,5 +103,5 @@
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"version": "1.13.0" "version": "1.13.1"
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,10 +1,10 @@
import type { Context } from 'hono'; import type { Context } from 'hono';
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app'; import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context'; import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
import { createOpenApiFetchHandler } from '@documenso/trpc/utils/openapi-fetch-handler';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler'; import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => { export const openApiTrpcServerHandler = async (c: Context) => {

View File

@@ -85,6 +85,7 @@ export default defineConfig({
'nodemailer', 'nodemailer',
/playwright/, /playwright/,
'@playwright/browser-chromium', '@playwright/browser-chromium',
'skia-canvas',
], ],
}, },
}, },

828
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.13.0", "version": "1.13.1",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",
@@ -44,7 +44,7 @@
"@commitlint/cli": "^17.7.1", "@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0", "@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0", "@lingui/cli": "^5.2.0",
"@prisma/client": "^6.8.2", "@prisma/client": "^6.18.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^8.40.0", "eslint": "^8.40.0",
@@ -54,11 +54,21 @@
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"playwright": "1.52.0", "playwright": "1.52.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prisma": "^6.8.2", "prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0", "prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0", "prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3", "turbo": "^1.9.3",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"zod-openapi": "^4.2.4",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"zod-prisma-types": "3.3.5",
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"name": "@documenso/root", "name": "@documenso/root",
@@ -76,12 +86,12 @@
"mupdf": "^1.0.0", "mupdf": "^1.0.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "3.24.1" "zod": "^3.25.76"
}, },
"overrides": { "overrides": {
"zod": "3.24.1" "zod": "^3.25.76"
}, },
"trigger.dev": { "trigger.dev": {
"endpointId": "documenso-app" "endpointId": "documenso-app"
} }
} }

View File

@@ -17,14 +17,14 @@
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5", "@ts-rest/core": "^3.52.0",
"@ts-rest/open-api": "^3.33.0", "@ts-rest/open-api": "^3.52.0",
"@ts-rest/serverless": "^3.30.5", "@ts-rest/serverless": "^3.52.0",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"superjson": "^1.13.1", "superjson": "^2.2.5",
"swagger-ui-react": "^5.21.0", "swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@@ -427,6 +427,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
globalAccessAuth: body.authOptions?.globalAccessAuth, globalAccessAuth: body.authOptions?.globalAccessAuth,
globalActionAuth: body.authOptions?.globalActionAuth, globalActionAuth: body.authOptions?.globalActionAuth,
}, },
attachments: body.attachments,
meta: { meta: {
subject: body.meta.subject, subject: body.meta.subject,
message: body.meta.message, message: body.meta.message,
@@ -497,6 +498,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription, publicDescription,
type, type,
meta, meta,
attachments,
} = body; } = body;
try { try {
@@ -568,6 +570,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription, publicDescription,
}, },
meta, meta,
attachments,
requestMetadata: metadata, requestMetadata: metadata,
}); });
@@ -792,6 +795,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
...body.meta, ...body.meta,
title: body.title, title: body.title,
}, },
attachments: body.attachments,
requestMetadata: metadata, requestMetadata: metadata,
}); });

View File

@@ -22,6 +22,7 @@ import {
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
extendZodWithOpenApi(z); extendZodWithOpenApi(z);
@@ -197,6 +198,15 @@ export const ZCreateDocumentMutationSchema = z.object({
description: 'The globalActionAuth property is only available for Enterprise accounts.', description: 'The globalActionAuth property is only available for Enterprise accounts.',
}), }),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>; export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
@@ -262,6 +272,15 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
}) })
.optional(), .optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export type TCreateDocumentFromTemplateMutationSchema = z.infer< export type TCreateDocumentFromTemplateMutationSchema = z.infer<

View File

@@ -69,11 +69,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dict
// Verify next recipient info is shown // Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible(); await expect(page.getByText('Next Recipient Name')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
await page.waitForTimeout(1000);
// Use dialog context to ensure we're targeting the correct form fields // Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');

View File

@@ -458,7 +458,12 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
expect(status).toBe(DocumentStatus.PENDING); expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Approve' }).click(); await page.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible(); await expect(
page
.getByRole('dialog')
.getByText('You are about to complete approving the following document')
.first(),
).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click(); await page.getByRole('button', { name: 'Approve' }).click();
await page.waitForURL('https://documenso.com'); await page.waitForURL('https://documenso.com');

View File

@@ -268,17 +268,19 @@ test('[TEMPLATE]: should create a document from a template with custom document'
// Upload document. // Upload document.
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'), page.waitForEvent('filechooser'),
page.getByTestId('template-use-dialog-file-input').evaluate((e) => { page
if (e instanceof HTMLInputElement) { .locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
e.click(); .evaluate((e) => {
} if (e instanceof HTMLInputElement) {
}), e.click();
}
}),
]); ]);
await fileChooser.setFiles(EXAMPLE_PDF_PATH); await fileChooser.setFiles(EXAMPLE_PDF_PATH);
// Wait for upload to complete // Wait for upload to complete
await expect(page.getByText(path.basename(EXAMPLE_PDF_PATH))).toBeVisible(); await expect(page.getByText('Remove')).toBeVisible();
// Create document with custom document data // Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();
@@ -367,17 +369,19 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
// Upload document. // Upload document.
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'), page.waitForEvent('filechooser'),
page.getByTestId('template-use-dialog-file-input').evaluate((e) => { page
if (e instanceof HTMLInputElement) { .locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
e.click(); .evaluate((e) => {
} if (e instanceof HTMLInputElement) {
}), e.click();
}
}),
]); ]);
await fileChooser.setFiles(EXAMPLE_PDF_PATH); await fileChooser.setFiles(EXAMPLE_PDF_PATH);
// Wait for upload to complete // Wait for upload to complete
await expect(page.getByText(path.basename(EXAMPLE_PDF_PATH))).toBeVisible(); await expect(page.getByText('Remove')).toBeVisible();
// Create document with custom document data // Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();

View File

@@ -83,7 +83,7 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
await expect(page.getByText('Direct link signing has been').first()).toBeVisible(); await expect(page.getByText('Direct link signing has been').first()).toBeVisible();
// Check that the direct template link is no longer accessible. // Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); await page.goto(formatDirectTemplatePath(template.directLink?.token || '123'));
await expect(page.getByText('404 not found')).toBeVisible(); await expect(page.getByText('404 not found')).toBeVisible();
}); });

View File

@@ -20,6 +20,6 @@
"luxon": "^3.5.0", "luxon": "^3.5.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@@ -19,6 +19,6 @@
"micro": "^10.0.1", "micro": "^10.0.1",
"react": "^18", "react": "^18",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "3.24.1" "zod": "^3.25.76"
} }
} }

View File

@@ -1,6 +1,6 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants'; import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, FREE_PLAN_LIMITS } from './constants';
import type { TLimitsResponseSchema } from './schema'; import type { TLimitsResponseSchema } from './schema';
import { ZLimitsResponseSchema } from './schema'; import { ZLimitsResponseSchema } from './schema';
@@ -29,6 +29,7 @@ export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
return { return {
quota: FREE_PLAN_LIMITS, quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS, remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
} satisfies TLimitsResponseSchema; } satisfies TLimitsResponseSchema;
}); });
}; };

View File

@@ -23,3 +23,8 @@ export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
recipients: Infinity, recipients: Infinity,
directTemplates: Infinity, directTemplates: Infinity,
}; };
/**
* Used as an initial value for the frontend before values are loaded from the server.
*/
export const DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT = 5;

View File

@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { isDeepEqual } from 'remeda'; import { isDeepEqual } from 'remeda';
import { getLimits } from '../client'; import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants'; import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, FREE_PLAN_LIMITS } from '../constants';
import type { TLimitsResponseSchema } from '../schema'; import type { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema & { refreshLimits: () => Promise<void> }; export type LimitsContextValue = TLimitsResponseSchema & { refreshLimits: () => Promise<void> };
@@ -30,6 +30,7 @@ export const LimitsProvider = ({
initialValue = { initialValue = {
quota: FREE_PLAN_LIMITS, quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS, remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
}, },
teamId, teamId,
children, children,

View File

@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from './constants';
// Not proud of the below but it's a way to deal with Infinity when returning JSON. // Not proud of the below but it's a way to deal with Infinity when returning JSON.
export const ZLimitsSchema = z.object({ export const ZLimitsSchema = z.object({
documents: z documents: z
@@ -21,6 +23,7 @@ export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
export const ZLimitsResponseSchema = z.object({ export const ZLimitsResponseSchema = z.object({
quota: ZLimitsSchema, quota: ZLimitsSchema,
remaining: ZLimitsSchema, remaining: ZLimitsSchema,
maximumEnvelopeItemCount: z.number().optional().default(DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT),
}); });
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>; export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;

View File

@@ -23,13 +23,6 @@ export const getServerLimits = async ({
userId, userId,
teamId, teamId,
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => { }: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
};
}
const organisation = await prisma.organisation.findFirst({ const organisation = await prisma.organisation.findFirst({
where: { where: {
teams: { teams: {
@@ -57,12 +50,22 @@ export const getServerLimits = async ({
const remaining = structuredClone(FREE_PLAN_LIMITS); const remaining = structuredClone(FREE_PLAN_LIMITS);
const subscription = organisation.subscription; const subscription = organisation.subscription;
const maximumEnvelopeItemCount = organisation.organisationClaim.envelopeItemCount;
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
maximumEnvelopeItemCount,
};
}
// Bypass all limits even if plan expired for ENTERPRISE. // Bypass all limits even if plan expired for ENTERPRISE.
if (organisation.organisationClaimId === INTERNAL_CLAIM_ID.ENTERPRISE) { if (organisation.organisationClaimId === INTERNAL_CLAIM_ID.ENTERPRISE) {
return { return {
quota: PAID_PLAN_LIMITS, quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS, remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount,
}; };
} }
@@ -71,6 +74,7 @@ export const getServerLimits = async ({
return { return {
quota: INACTIVE_PLAN_LIMITS, quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS, remaining: INACTIVE_PLAN_LIMITS,
maximumEnvelopeItemCount,
}; };
} }
@@ -80,6 +84,7 @@ export const getServerLimits = async ({
return { return {
quota: PAID_PLAN_LIMITS, quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS, remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount,
}; };
} }
@@ -117,5 +122,6 @@ export const getServerLimits = async ({
return { return {
quota, quota,
remaining, remaining,
maximumEnvelopeItemCount,
}; };
}; };

View File

@@ -1,3 +1,5 @@
import { match } from 'ts-pattern';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants'; import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { TCheckboxFieldMeta } from '../types/field-meta'; import type { TCheckboxFieldMeta } from '../types/field-meta';
@@ -75,3 +77,15 @@ export const validateCheckboxField = (
return errors; return errors;
}; };
export const validateCheckboxLength = (
numberOfSelectedOptions: number,
validationRule: '>=' | '=' | '<=',
validationLength: number,
) => {
return match(validationRule)
.with('>=', () => numberOfSelectedOptions >= validationLength)
.with('=', () => numberOfSelectedOptions === validationLength)
.with('<=', () => numberOfSelectedOptions <= validationLength)
.exhaustive();
};

View File

@@ -29,7 +29,7 @@ export const validateNumberField = (
errors.push('Value is required'); errors.push('Value is required');
} }
if (!/^[0-9,.]+$/.test(value.trim())) { if ((isSigningPage || value.length > 0) && !/^[0-9,.]+$/.test(value.trim())) {
errors.push(`Value is not a valid number`); errors.push(`Value is not a valid number`);
} }

View File

@@ -123,7 +123,6 @@ export const useEditorFields = ({
} }
if (bypassCheck) { if (bypassCheck) {
console.log(3);
setSelectedFieldFormId(formId); setSelectedFieldFormId(formId);
return; return;
} }
@@ -136,6 +135,7 @@ export const useEditorFields = ({
const field: TLocalField = { const field: TLocalField = {
...fieldData, ...fieldData,
formId: nanoid(12), formId: nanoid(12),
...restrictFieldPosValues(fieldData),
}; };
append(field); append(field);
@@ -165,7 +165,15 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId); const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) { if (index !== -1) {
update(index, { ...localFields[index], ...updates }); const updatedField = {
...localFields[index],
...updates,
};
update(index, {
...updatedField,
...restrictFieldPosValues(updatedField),
});
triggerFieldsUpdate(); triggerFieldsUpdate();
} }
}, },
@@ -279,3 +287,14 @@ export const useEditorFields = ({
setSelectedRecipient, setSelectedRecipient,
}; };
}; };
const restrictFieldPosValues = (
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
) => {
return {
positionX: Math.max(0, Math.min(100, field.positionX)),
positionY: Math.max(0, Math.min(100, field.positionY)),
width: Math.max(0, Math.min(100, field.width)),
height: Math.max(0, Math.min(100, field.height)),
};
};

View File

@@ -0,0 +1,126 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
);
/**
* The viewport scaled according to page width.
*/
const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
/**
* Viewport with the device pixel ratio applied so we can render the PDF
* in a higher resolution.
*/
const renderViewport = useMemo(
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
[page, rotate, scale],
);
/**
* Render the PDF and create the scaled Konva stage.
*/
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: kContainer } = konvaContainer;
if (!canvas || !kContainer) {
return;
}
canvas.width = renderViewport.width;
canvas.height = renderViewport.height;
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: renderViewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
stage.current = new Konva.Stage({
container: kContainer,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
);
return {
canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
pageContext,
};
}

View File

@@ -5,15 +5,14 @@ import { EnvelopeType } from '@prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types'; import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { RecipientColorStyles, TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import { import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
AVAILABLE_RECIPIENT_COLORS, import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
getRecipientColorStyles,
} from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TDocumentEmailSettings } from '../../types/document-email'; import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TEnvelope } from '../../types/envelope'; import type { TEnvelope } from '../../types/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useEditorFields } from '../hooks/use-editor-fields'; import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields'; import type { TLocalField } from '../hooks/use-editor-fields';
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave'; import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
@@ -38,25 +37,35 @@ export const useDebounceFunction = <Args extends unknown[]>(
); );
}; };
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
type EnvelopeEditorProviderValue = { type EnvelopeEditorProviderValue = {
envelope: TEnvelope; envelope: TEnvelope;
isDocument: boolean; isDocument: boolean;
isTemplate: boolean; isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void; setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: Partial<TEnvelope>) => void; updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void; setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>; setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
getFieldColor: (field: TLocalField) => RecipientColorStyles;
getRecipientColorKey: (recipientId: number) => TRecipientColor; getRecipientColorKey: (recipientId: number) => TRecipientColor;
editorFields: ReturnType<typeof useEditorFields>; editorFields: ReturnType<typeof useEditorFields>;
isAutosaving: boolean; isAutosaving: boolean;
flushAutosave: () => void; flushAutosave: () => Promise<void>;
autosaveError: boolean; autosaveError: boolean;
relativePath: {
basePath: string;
envelopePath: string;
editorPath: string;
documentRootPath: string;
templateRootPath: string;
};
syncEnvelope: () => Promise<void>;
// refetchEnvelope: () => Promise<void>; // refetchEnvelope: () => Promise<void>;
// updateEnvelope: (envelope: TEnvelope) => Promise<void>; // updateEnvelope: (envelope: TEnvelope) => Promise<void>;
}; };
@@ -86,12 +95,10 @@ export const EnvelopeEditorProvider = ({
const { toast } = useToast(); const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope); const [envelope, setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false); const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({ const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => { onSuccess: (response, input) => {
console.log(input.meta?.emailSettings);
setEnvelope({ setEnvelope({
...envelope, ...envelope,
...response, ...response,
@@ -106,7 +113,9 @@ export const EnvelopeEditorProvider = ({
setAutosaveError(false); setAutosaveError(false);
}, },
onError: (error) => { onError: (err) => {
console.error(err);
setAutosaveError(true); setAutosaveError(true);
toast({ toast({
@@ -122,7 +131,9 @@ export const EnvelopeEditorProvider = ({
onSuccess: () => { onSuccess: () => {
setAutosaveError(false); setAutosaveError(false);
}, },
onError: (error) => { onError: (err) => {
console.error(err);
setAutosaveError(true); setAutosaveError(true);
toast({ toast({
@@ -135,10 +146,17 @@ export const EnvelopeEditorProvider = ({
}); });
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({ const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: () => { onSuccess: ({ recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
}));
setAutosaveError(false); setAutosaveError(false);
}, },
onError: (error) => { onError: (err) => {
console.error(err);
setAutosaveError(true); setAutosaveError(true);
toast({ toast({
@@ -178,21 +196,28 @@ export const EnvelopeEditorProvider = ({
triggerSave: setEnvelopeDebounced, triggerSave: setEnvelopeDebounced,
flush: setEnvelopeAsync, flush: setEnvelopeAsync,
isPending: isEnvelopeMutationPending, isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: Partial<TEnvelope>) => { } = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({ await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id, envelopeId: envelope.id,
envelopeType: envelope.type, envelopeType: envelope.type,
data: { data: envelopeUpdates.data,
...envelopeUpdates, meta: envelopeUpdates.meta,
},
}); });
}, 1000); }, 1000);
/** /**
* Updates the local envelope and debounces the update to the server. * Updates the local envelope and debounces the update to the server.
*/ */
const updateEnvelope = (envelopeUpdates: Partial<TEnvelope>) => { const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
setEnvelope((prev) => ({ ...prev, ...envelopeUpdates })); setEnvelope((prev) => ({
...prev,
...envelopeUpdates.data,
meta: {
...prev.documentMeta,
...envelopeUpdates.meta,
},
}));
setEnvelopeDebounced(envelopeUpdates); setEnvelopeDebounced(envelopeUpdates);
}; };
@@ -201,28 +226,17 @@ export const EnvelopeEditorProvider = ({
handleFieldsUpdate: (fields) => setFieldsDebounced(fields), handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
}); });
const getFieldColor = useCallback(
(field: TLocalField) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === field.recipientId,
);
return getRecipientColorStyles(Math.max(recipientIndex, 0));
},
[envelope.recipients], // Todo: Envelopes - Local recipients
);
const getRecipientColorKey = useCallback( const getRecipientColorKey = useCallback(
(recipientId: number) => { (recipientId: number) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex( const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId, (recipient) => recipient.id === recipientId,
); );
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)]; return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
}, },
[envelope.recipients], // Todo: Envelopes - Local recipients [envelope.recipients],
); );
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery( const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
@@ -234,6 +248,21 @@ export const EnvelopeEditorProvider = ({
}, },
); );
/**
* Fetch and sycn the envelope back into the editor.
*
* Overrides everything.
*/
const syncEnvelope = async () => {
await flushAutosave();
const fetchedEnvelopeData = await reloadEnvelope();
if (fetchedEnvelopeData.data) {
setEnvelope(fetchedEnvelopeData.data);
}
};
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => { const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...localEnvelope })); setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
}; };
@@ -256,10 +285,23 @@ export const EnvelopeEditorProvider = ({
isEnvelopeMutationPending, isEnvelopeMutationPending,
]); ]);
const flushAutosave = () => { const relativePath = useMemo(() => {
void setFieldsAsync(); const documentRootPath = formatDocumentsPath(envelope.team.url);
void setRecipientsAsync(); const templateRootPath = formatTemplatesPath(envelope.team.url);
void setEnvelopeAsync();
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
return {
basePath,
envelopePath: `${basePath}/${envelope.id}`,
editorPath: `${basePath}/${envelope.id}/edit`,
documentRootPath,
templateRootPath,
};
}, [envelope.type, envelope.id]);
const flushAutosave = async (): Promise<void> => {
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
}; };
return ( return (
@@ -269,7 +311,6 @@ export const EnvelopeEditorProvider = ({
isDocument: envelope.type === EnvelopeType.DOCUMENT, isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE, isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope, setLocalEnvelope,
getFieldColor,
getRecipientColorKey, getRecipientColorKey,
updateEnvelope, updateEnvelope,
setRecipientsDebounced, setRecipientsDebounced,
@@ -278,6 +319,8 @@ export const EnvelopeEditorProvider = ({
autosaveError, autosaveError,
flushAutosave, flushAutosave,
isAutosaving, isAutosaving,
relativePath,
syncEnvelope,
}} }}
> >
{children} {children}

View File

@@ -2,6 +2,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 12; export const DEFAULT_STANDARD_FONT_SIZE = 12;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50; export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const DEFAULT_SIGNATURE_TEXT_FONT_SIZE = 18;
export const MIN_STANDARD_FONT_SIZE = 8; export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20; export const MIN_HANDWRITING_FONT_SIZE = 20;

View File

@@ -18,6 +18,8 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
embedSigning: z.literal(true).optional(), embedSigning: z.literal(true).optional(),
embedSigningWhiteLabel: z.literal(true).optional(), embedSigningWhiteLabel: z.literal(true).optional(),
cfr21: z.literal(true).optional(), cfr21: z.literal(true).optional(),
// Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here.
}), }),
}); });

View File

@@ -1,4 +1,12 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import {
PDFDocument,
RotationTypes,
popGraphicsState,
pushGraphicsState,
radiansToDegrees,
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client'; import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client';
import { import {
DocumentStatus, DocumentStatus,
@@ -9,6 +17,8 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import path from 'node:path'; import path from 'node:path';
import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing'; import { signPdf } from '@documenso/signing';
@@ -21,6 +31,7 @@ import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificat
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf'; import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations'; import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form'; import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { getPageSize } from '../../../server-only/pdf/get-page-size';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1'; import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2'; import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf'; import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
@@ -178,9 +189,10 @@ export const run = async ({
settings, settings,
}); });
// Todo: Envelopes - Is it okay to have dynamic IDs?
const newDocumentData = await Promise.all( const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) => envelopeItems.map(async (envelopeItem) =>
io.runTask('decorate-and-sign-pdf', async () => { io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find( const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id, (item) => item.id === envelopeItem.id,
)?.field; )?.field;
@@ -353,14 +365,95 @@ const decorateAndSignPdf = async ({
}); });
} }
for (const field of envelopeItemFields) { // Handle V1 and legacy insertions.
if (field.inserted) { if (envelope.internalVersion === 1) {
if (envelope.internalVersion === 2) { for (const field of envelopeItemFields) {
await insertFieldInPDFV2(pdfDoc, field); if (field.inserted) {
} else if (envelope.useLegacyFieldInsertion) { if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field); await legacy_insertFieldInPDF(pdfDoc, field);
} else { } else {
await insertFieldInPDFV1(pdfDoc, field); await insertFieldInPDFV1(pdfDoc, field);
}
}
}
}
// Handle V2 envelope insertions.
if (envelope.internalVersion === 2) {
const fieldsGroupedByPage = groupBy(envelopeItemFields, (field) => field.page);
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
const pageRotation = page.getRotation();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
// Rotate the page to the orientation that the react-pdf renders on the frontend.
// Note: These transformations are undone at the end of the function.
// If you change this if statement, update the if statement at the end as well
if (pageRotationInDegrees !== 0) {
let translateX = 0;
let translateY = 0;
switch (pageRotationInDegrees) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
case 0:
default:
translateX = 0;
translateY = 0;
}
page.pushOperators(pushGraphicsState());
page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees));
}
const renderedPdfOverlay = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields,
});
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay);
// Draw the SVG on the page
page.drawPage(embeddedPage, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
// Remove the transformations applied to the page if any were applied.
if (pageRotationInDegrees !== 0) {
page.pushOperators(popGraphicsState());
} }
} }
} }

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