Compare commits

..

6 Commits

Author SHA1 Message Date
a66a56042c feat: add envelopes api 2025-10-28 12:13:26 +11:00
b0b07106b4 fix: envelope autosave (#2103) 2025-10-27 19:53:35 +11:00
35250fa308 feat: server port configurable via PORT env (#2097) 2025-10-27 17:24:24 +11:00
5cdd7f8623 fix: envelope styling (#2102) 2025-10-27 16:11:10 +11:00
47bdcd833f chore: extract translations (#2094) 2025-10-24 16:37:10 +11:00
03eb6af69a feat: polish envelopes (#2090)
## Description

The rest of the owl
2025-10-24 16:22:06 +11:00
206 changed files with 9136 additions and 4255 deletions

View File

@ -29,6 +29,10 @@ NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
# URL used by the web app to request itself (e.g. local background jobs) # URL used by the web app to request itself (e.g. local background jobs)
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
# [[SERVER]]
# OPTIONAL: The port the server will listen on. Defaults to 3000.
PORT=3000
# [[DATABASE]] # [[DATABASE]]
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool. # Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.

View File

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev -p 3003", "dev": "next dev -p 3003",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -p 3003",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules" "clean": "rimraf .next && rimraf node_modules"
}, },

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,17 +15,16 @@ 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 { useNavigate } from 'react-router';
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';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -61,8 +60,10 @@ 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>;
documentRootPath: string;
trigger?: React.ReactNode; trigger?: React.ReactNode;
}; };
@ -84,13 +85,19 @@ 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,
documentRootPath,
onDistribute,
}: EnvelopeDistributeDialogProps) => {
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const recipients = envelope.recipients; const recipients = envelope.recipients;
const { toast } = useToast(); const { toast } = useToast();
const { t } = useLingui(); const { t } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -127,22 +134,44 @@ 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?.();
let redirectPath = `${documentRootPath}/${envelope.id}`;
if (meta.distributionMethod === DocumentDistributionMethod.NONE) {
redirectPath += '?action=copy-links';
}
await navigate(redirectPath);
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 +207,8 @@ 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}>
@ -200,7 +230,11 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
</TabsList> </TabsList>
</Tabs> </Tabs>
<div className="min-h-72"> <div
className={cn('min-h-72', {
'min-h-[23rem]': organisation.organisationClaim.flags.emailDomains,
})}
>
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">
{distributionMethod === DocumentDistributionMethod.EMAIL && ( {distributionMethod === DocumentDistributionMethod.EMAIL && (
<motion.div <motion.div
@ -335,7 +369,6 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
exit={{ opacity: 0, transition: { duration: 0.15 } }} exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="min-h-60 rounded-lg border" className="min-h-60 rounded-lg border"
> >
{envelope.status === DocumentStatus.DRAFT ? (
<div className="text-muted-foreground py-24 text-center text-sm"> <div className="text-muted-foreground py-24 text-center text-sm">
<p> <p>
<Trans>We won't send anything to notify recipients.</Trans> <Trans>We won't send anything to notify recipients.</Trans>
@ -348,58 +381,6 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
</Trans> </Trans>
</p> </p>
</div> </div>
) : (
<ul className="text-muted-foreground divide-y">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li
key={recipient.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={
<p className="text-muted-foreground text-sm">
{recipient.email}
</p>
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{t(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
{recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: t`Copied to clipboard`,
description: t`The signing link has been copied to your clipboard.`,
});
}}
badgeContentUncopied={
<p className="ml-1 text-xs">
<Trans>Copy</Trans>
</p>
}
badgeContentCopied={
<p className="ml-1 text-xs">
<Trans>Copied</Trans>
</p>
}
/>
)}
</li>
))}
</ul>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@ -426,12 +407,24 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
) : ( ) : (
<> <>
<Alert variant="warning"> <Alert variant="warning">
{match(invalidEnvelopeCode)
.with('MISSING_RECIPIENTS', () => (
<AlertDescription> <AlertDescription>
<Trans> <Trans>You need at least one recipient to send a document</Trans>
Some signers have not been assigned a signature field. Please assign at least 1
signature field to each signer before proceeding.
</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,220 @@
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>
))
)}
</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>
<DialogDescription className="mt-4">
<Trans>Select a value to sign into the field</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
> >
<FormField <CommandInput placeholder={t`Select an option`} />
control={form.control} <CommandList>
name="dropdown" <CommandEmpty>No results found.</CommandEmpty>
render={({ field }) => ( <CommandGroup heading={t`Options`}>
<FormItem>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue placeholder={t`Select an option`} />
</SelectTrigger>
<SelectContent>
{values.map((value, i) => ( {values.map((value, i) => (
<SelectItem key={i} value={value}> <CommandItem onSelect={() => call.end(value)} key={i} value={value}>
{value} {value}
</SelectItem> </CommandItem>
))} ))}
</SelectContent> </CommandGroup>
</Select> </CommandList>
</FormControl> </CommandDialog>
<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

@ -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,57 +456,88 @@ export function TemplateUseDialog({
/> />
{form.watch('useCustomDocument') && ( {form.watch('useCustomDocument') && (
<div className="my-4"> <div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
) : (
localCustomDocumentData.map((item, i) => (
<FormField <FormField
key={item.id}
control={form.control} control={form.control}
name="customDocumentData" name={`customDocumentData.${i}.data`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<div className="w-full space-y-4"> <div
<label key={item.id}
className={cn( className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
'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',
{
'border-destructive hover:border-destructive':
form.formState.errors.customDocumentData,
},
)}
> >
<div className="text-center"> <div className="flex-shrink-0">
{!field.value && ( <div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<> <FileTextIcon className="text-primary h-5 w-5" />
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" /> </div>
<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> </div>
<p className="text-muted-foreground/80 text-xs">
PDF files only
</p>
</>
)}
{field.value && ( <div className="min-w-0 flex-1">
<div className="text-muted-foreground space-y-1"> <h4 className="text-foreground truncate text-sm font-medium">
<p className="text-sm font-medium">{field.value.name}</p> {item.title}
<p className="text-muted-foreground/60 text-xs"> </h4>
{(field.value.size / (1024 * 1024)).toFixed(2)} MB <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> </p>
</div> </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> </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 <input
type="file" type="file"
data-testid="template-use-dialog-file-input" id={`template-use-dialog-file-input-${item.envelopeItemId}`}
className="absolute h-full w-full opacity-0" className="hidden"
accept=".pdf,application/pdf" accept=".pdf,application/pdf"
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@ -476,7 +557,10 @@ export function TemplateUseDialog({
return; return;
} }
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) { if (
file.size >
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
) {
form.setError('customDocumentData', { form.setError('customDocumentData', {
type: 'manual', type: 'manual',
message: _( message: _(
@ -490,32 +574,15 @@ export function TemplateUseDialog({
field.onChange(file); 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> </div>
</Button>
</div>
)}
</label>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
))
)}
</div> </div>
)} )}
</div> </div>

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,12 +31,12 @@ 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({
@ -45,39 +48,33 @@ const ZDropdownFieldFormSchema = z
.min(1, { .min(1, {
message: msg`Dropdown must have at least one option`.id, message: msg`Dropdown must have at least one option`.id,
}) })
.refine( .superRefine((values, ctx) => {
(data) => { const seen = new Map<string, number[]>(); // value → indices
// Todo: Envelopes - This doesn't work.
console.log({ values.forEach((item, index) => {
data, const key = item.value;
if (!seen.has(key)) {
seen.set(key, []);
}
seen.get(key)!.push(index);
}); });
if (data) { for (const [key, indices] of seen) {
const values = data.map((item) => item.value); if (indices.length > 1 && key.trim() !== '') {
return new Set(values).size === values.length; 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(), required: z.boolean().optional(),
readOnly: z.boolean().optional(), readOnly: z.boolean().optional(),
}) fontSize: z.number().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;
},
{
message: 'Default value must be one of the available options',
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 || [])
.filter((item) => item.value)
.map((item, index) => (
<SelectItem key={index} value={item.value || ''}> <SelectItem key={index} value={item.value || ''}>
{item.value} {item.value}
</SelectItem> </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,34 +1,49 @@
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(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => { (data) => {
// There cannot be more than one checked option // There cannot be more than one checked option
if (data.values) { if (data.values) {
@ -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,74 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
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">
<div>
<EditorGenericFontSizeField formControl={form.control} />
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>The typed signature font size</Trans>
</p>
</div>
</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

@ -8,11 +8,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive
export type DocumentSigningAttachmentsPopoverProps = { export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string; envelopeId: string;
token: string; token: string;
trigger?: React.ReactNode;
}; };
export const DocumentSigningAttachmentsPopover = ({ export const DocumentSigningAttachmentsPopover = ({
envelopeId, envelopeId,
token, token,
trigger,
}: DocumentSigningAttachmentsPopoverProps) => { }: DocumentSigningAttachmentsPopoverProps) => {
const { data: attachments } = trpc.envelope.attachment.find.useQuery({ const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId, envelopeId,
@ -26,6 +28,7 @@ export const DocumentSigningAttachmentsPopover = ({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
{trigger ?? (
<Button variant="outline" className="gap-2"> <Button variant="outline" className="gap-2">
<PaperclipIcon className="h-4 w-4" /> <PaperclipIcon className="h-4 w-4" />
<span> <span>
@ -35,6 +38,7 @@ export const DocumentSigningAttachmentsPopover = ({
)} )}
</span> </span>
</Button> </Button>
)}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-96" align="start"> <PopoverContent className="w-96" align="start">

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}>
{!showTwoFactorForm && ( <DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle> <DialogTitle>
<div className="text-foreground text-xl font-semibold"> <Trans>Are you sure?</Trans>
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle> </DialogTitle>
<DialogDescription>
<div className="text-muted-foreground max-w-[50ch]"> <div className="text-muted-foreground max-w-[50ch]">
{match(recipient.role) {match(recipient.role)
.with(RecipientRole.VIEWER, () => ( .with(RecipientRole.VIEWER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap"> <span className="inline-flex flex-wrap">
You are about to complete viewing " <Trans>You are about to complete viewing the following document</Trans>
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span> </span>
)) ))
.with(RecipientRole.SIGNER, () => ( .with(RecipientRole.SIGNER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap"> <span className="inline-flex flex-wrap">
You are about to complete signing " <Trans>You are about to complete signing the following document</Trans>
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span> </span>
)) ))
.with(RecipientRole.APPROVER, () => ( .with(RecipientRole.APPROVER, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap"> <span className="inline-flex flex-wrap">
You are about to complete approving{' '} <Trans>You are about to complete approving the following document</Trans>
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span> </span>
)) ))
.otherwise(() => ( .with(RecipientRole.ASSISTANT, () => (
<span>
<Trans>
<span className="inline-flex flex-wrap"> <span className="inline-flex flex-wrap">
You are about to complete viewing " <Trans>You are about to complete assisting the following document</Trans>
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span> </span>
". ))
</span> .with(RecipientRole.CC, () => null)
<br /> Are you sure? .exhaustive()}
</Trans> </div>
</span> </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> </div>
{allowDictateNextSigner && ( {!showTwoFactorForm && (
<div className="mt-4 flex flex-col gap-4"> <>
{!isEditingNextSigner && ( <fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<div> {directTemplatePayload && !directTemplatePayload.email && (
<p className="text-muted-foreground text-sm"> <Form {...directRecipientForm}>
The next recipient to sign this document will be{' '} <div className="mb-4 flex flex-col gap-4">
<span className="font-semibold">{form.watch('name')}</span> ( <div className="flex flex-col gap-4 md:flex-row">
<span className="font-semibold">{form.watch('email')}</span>). <FormField
</p> control={directRecipientForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Your Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
</FormControl>
<Button <FormMessage />
type="button" </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" className="mt-2"
variant="outline" placeholder={t`Enter your email`}
size="sm" />
onClick={() => setIsEditingNextSigner((prev) => !prev)} </FormControl>
> <FormMessage />
<Trans>Update Recipient</Trans> </FormItem>
</Button> )}
/>
</div> </div>
</div>
</Form>
)} )}
{isEditingNextSigner && ( <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
<div className="mb-4 flex flex-col gap-4">
{/* Todo: Envelopes - Should we say "The next recipient to sign this document will be"? */}
<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>
</fieldset>
</form> </form>
</Form> </Form>
</fieldset>
</>
)} )}
{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

@ -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, PaperclipIcon } 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';
@ -23,6 +27,8 @@ import { DocumentSigningAttachmentsPopover } from '../document-signing/document-
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(
@ -33,15 +39,30 @@ export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { const {
isDirectTemplate,
envelope, envelope,
recipient, recipient,
recipientFields, recipientFields,
recipientFieldsRemaining, recipientFieldsRemaining,
showPendingFieldTooltip, requiredRecipientFields,
selectedAssistantRecipientFields,
} = useRequiredEnvelopeSigningContext(); } = 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 />
@ -49,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-4rem)] 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>
@ -71,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>
@ -84,22 +115,41 @@ export const DocumentSigningPageViewV2 = () => {
<Separator className="my-6" /> <Separator className="my-6" />
{/* Quick Actions. */} {/* Quick Actions. */}
{!isDirectTemplate && (
<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>Actions</Trans> <Trans>Actions</Trans>
</h4> </h4>
<div className="w-full"> <DocumentSigningAttachmentsPopover
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} /> envelopeId={envelope.id}
</div> token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
{/* Todo: Allow selecting which document to download and/or the original */} <EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" /> <DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download Original</Trans> <Trans>Download PDF</Trans>
</Button> </Button>
}
/>
{/* Todo: Envelopes */} {envelope.type === EnvelopeType.DOCUMENT && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
trigger={
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -108,7 +158,11 @@ export const DocumentSigningPageViewV2 = () => {
<BanIcon className="mr-2 h-4 w-4" /> <BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans> <Trans>Reject Document</Trans>
</Button> </Button>
}
/>
)}
</div> </div>
)}
{/* Footer of left sidebar. */} {/* Footer of left sidebar. */}
<div className="mt-auto px-4"> <div className="mt-auto px-4">
@ -121,11 +175,11 @@ 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 && (
<div className="flex h-fit space-x-2 overflow-x-auto p-2 pt-4 sm:p-4">
{envelopeItems.map((doc, i) => ( {envelopeItems.map((doc, i) => (
<EnvelopeItemSelector <EnvelopeItemSelector
key={doc.id} key={doc.id}
@ -136,8 +190,7 @@ export const DocumentSigningPageViewV2 = () => {
one="1 Field" one="1 Field"
other="# Fields" other="# Fields"
value={ value={
recipientFieldsRemaining.filter((field) => field.envelopeItemId === doc.id) remainingFields.filter((field) => field.envelopeItemId === doc.id).length
.length
} }
/> />
} }
@ -146,22 +199,10 @@ export const DocumentSigningPageViewV2 = () => {
/> />
))} ))}
</div> </div>
{/* Document View */}
<div className="mt-4 flex justify-center 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>
)} )}
{/* Document View */}
<div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? ( {currentEnvelopeItem ? (
<PDFViewerKonvaLazy <PDFViewerKonvaLazy
key={currentEnvelopeItem.id} key={currentEnvelopeItem.id}
@ -175,6 +216,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>
{trigger ?? (
<Button variant="outline"> <Button variant="outline">
<Trans>Reject Document</Trans> <Trans>Reject Document</Trans>
</Button> </Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>

View File

@ -1,21 +1,29 @@
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,
RecipientRole, RecipientRole,
SigningStatus, SigningStatus,
} from '@prisma/client'; } from '@prisma/client';
import { prop, sortBy } from 'remeda';
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 +40,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 +94,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 +162,49 @@ export const EnvelopeSigningProvider = ({
})(), })(),
); );
/**
* The fields that are still required to be signed by the actual recipient.
*/
const recipientFieldsRemaining = useMemo(() => {
const requiredFields = envelopeData.recipient.fields
.filter((field) => isFieldUnsignedAndRequired(field))
.map((field) => {
const envelopeItem = envelope.envelopeItems.find(
(item) => item.id === field.envelopeItemId,
);
if (!envelopeItem) {
throw new Error('Missing envelope item');
}
return {
...field,
envelopeItemOrder: envelopeItem.order,
};
});
return sortBy(
requiredFields,
[prop('envelopeItemOrder'), 'asc'],
[prop('page'), 'asc'],
[prop('positionY'), 'asc'],
);
}, [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 +238,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 +287,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 +301,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 +377,7 @@ export const EnvelopeSigningProvider = ({
recipient, recipient,
recipientFieldsRemaining, recipientFieldsRemaining,
recipientFields, recipientFields,
requiredRecipientFields,
nextRecipient, nextRecipient,
otherRecipientCompletedFields, otherRecipientCompletedFields,
@ -277,7 +385,7 @@ export const EnvelopeSigningProvider = ({
assistantFields, assistantFields,
setSelectedAssistantRecipientId, setSelectedAssistantRecipientId,
selectedAssistantRecipient, selectedAssistantRecipient,
selectedRecipientFields, selectedAssistantRecipientFields,
signField, signField,
}} }}

View File

@ -10,6 +10,7 @@ import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Form, Form,
@ -24,6 +25,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentAttachmentsPopoverProps = { export type DocumentAttachmentsPopoverProps = {
envelopeId: string; envelopeId: string;
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
}; };
const ZAttachmentFormSchema = z.object({ const ZAttachmentFormSchema = z.object({
@ -33,7 +36,11 @@ const ZAttachmentFormSchema = z.object({
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>; type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => { export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
buttonSize,
}: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
@ -118,7 +125,7 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
return ( return (
<Popover open={isOpen} onOpenChange={setIsOpen}> <Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
<span> <span>
@ -215,9 +222,6 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
<Trans>Add</Trans>
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -230,6 +234,9 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
> >
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
<Trans>Add</Trans>
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@ -4,7 +4,10 @@ import { Trans } from '@lingui/react/macro';
import type { DocumentData, EnvelopeItem } from '@prisma/client'; import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
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 PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
@ -92,6 +95,16 @@ export const DocumentCertificateQRView = ({
</Dialog> </Dialog>
)} )}
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
formattedDate={formattedDate}
/>
</EnvelopeRenderProvider>
) : (
<>
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end"> <div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1> <h1 className="text-xl font-medium">{title}</h1>
@ -106,21 +119,62 @@ export const DocumentCertificateQRView = ({
</div> </div>
</div> </div>
<ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} /> <ShareDocumentDownloadButton
title={title}
documentData={envelopeItems[0].documentData}
/>
</div>
<div className="mt-12 w-full">
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</div>
</>
)}
</div>
);
};
type DocumentCertificateQrV2Props = {
title: string;
recipientCount: number;
formattedDate: string;
};
const DocumentCertificateQrV2 = ({
title,
recipientCount,
formattedDate,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
<p>
<Trans>{recipientCount} recipients</Trans>
</p>
<p>
<Trans>Completed on {formattedDate}</Trans>
</p>
</div>
</div>
{currentEnvelopeItem && (
<ShareDocumentDownloadButton
title={title}
documentData={currentEnvelopeItem.documentData}
/>
)}
</div> </div>
<div className="mt-12 w-full"> <div className="mt-12 w-full">
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} /> <EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@ -95,6 +95,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,6 +147,23 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{envelope.internalVersion === 2 ? (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
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 && ( {isComplete && (
<DropdownMenuItem onClick={onDownloadClick}> <DropdownMenuItem onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
@ -157,6 +175,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
<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

@ -1,7 +1,10 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import { import {
AlertTriangle, AlertTriangle,
CheckIcon, CheckIcon,
@ -12,7 +15,7 @@ import {
PlusIcon, PlusIcon,
UserIcon, UserIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Link } from 'react-router'; import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -24,6 +27,12 @@ import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { PopoverHover } from '@documenso/ui/primitives/popover'; import { PopoverHover } from '@documenso/ui/primitives/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = { export type DocumentPageViewRecipientsProps = {
@ -37,8 +46,24 @@ export const DocumentPageViewRecipients = ({
}: DocumentPageViewRecipientsProps) => { }: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const recipients = envelope.recipients; const recipients = envelope.recipients;
const [shouldHighlightCopyButtons, setShouldHighlightCopyButtons] = useState(false);
// Check for action=view-tokens query parameter and set highlighting state
useEffect(() => {
const hasViewTokensAction = searchParams.get('action') === 'copy-links';
if (hasViewTokensAction) {
setShouldHighlightCopyButtons(true);
// Remove the query parameter immediately
const params = new URLSearchParams(searchParams);
params.delete('action');
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
return ( return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border"> <section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
@ -69,7 +94,7 @@ export const DocumentPageViewRecipients = ({
</li> </li>
)} )}
{recipients.map((recipient) => ( {recipients.map((recipient, i) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm"> <li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText <AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()} avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
@ -159,15 +184,33 @@ export const DocumentPageViewRecipients = ({
{envelope.status === DocumentStatus.PENDING && {envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && ( recipient.role !== RecipientRole.CC && (
<TooltipProvider>
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
<TooltipTrigger asChild>
<div
className={shouldHighlightCopyButtons ? 'animate-pulse' : ''}
onClick={() => setShouldHighlightCopyButtons(false)}
>
<CopyTextButton <CopyTextButton
value={formatSigningLink(recipient.token)} value={formatSigningLink(recipient.token)}
onCopySuccess={() => { onCopySuccess={() => {
toast({ toast({
title: _(msg`Copied to clipboard`), title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`), description: _(
msg`The signing link has been copied to your clipboard.`,
),
}); });
setShouldHighlightCopyButtons(false);
}} }}
/> />
</div>
</TooltipTrigger>
<TooltipContent sideOffset={2}>
<Trans>Copy Signing Links</Trans>
<TooltipArrow className="fill-background" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
</div> </div>
</li> </li>

View File

@ -108,6 +108,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';
@ -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]);
@ -138,6 +140,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 +157,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({ toast({
title: title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
type === EnvelopeType.DOCUMENT duration: 5000,
? t`Your document failed to upload.` variant: 'destructive',
: t`Your template failed to upload.`, });
return;
}
toast({
title: t`Upload failed`,
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 +193,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,47 +56,7 @@ 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');
const { current: container } = canvasElement; const { current: container } = canvasElement;
if (!container) { if (!container) {
@ -120,6 +68,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 +79,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 +115,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 +123,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 +133,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 +161,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 +176,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 +208,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 +225,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 +245,36 @@ 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; x1 = pointerPosition.x / scale;
y1 = pointerPosition.y; y1 = pointerPosition.y / scale;
x2 = pointerPosition.x; x2 = pointerPosition.x / scale;
y2 = pointerPosition.y; y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: x1, x: x1,
@ -341,7 +285,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 +293,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 +310,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 +321,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 +366,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 +415,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 +497,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 +537,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 +592,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 +622,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

@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react'; import { FileTextIcon } from 'lucide-react';
import { Link } from 'react-router';
import { isDeepEqual } from 'remeda'; import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -20,6 +21,7 @@ import type {
TNameFieldMeta, TNameFieldMeta,
TNumberFieldMeta, TNumberFieldMeta,
TRadioFieldMeta, TRadioFieldMeta,
TSignatureFieldMeta,
TTextFieldMeta, TTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
@ -37,6 +39,7 @@ import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form'; import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form'; import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form'; import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form'; import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
@ -60,8 +63,8 @@ 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, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -104,12 +107,12 @@ export const EnvelopeEditorPageFields = () => {
return ( return (
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="flex w-full flex-col"> <div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex justify-center"> <div className="mt-4 flex h-full justify-center p-4">
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : ( ) : (
@ -128,17 +131,23 @@ 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-full 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>
{envelope.recipients.length === 0 ? ( {envelope.recipients.length === 0 ? (
<Alert variant="warning"> <Alert variant="warning">
<AlertDescription> <AlertDescription className="flex flex-col gap-2">
<Trans>You need at least one recipient to add fields</Trans> <Trans>You need at least one recipient to add fields</Trans>
<Link to={`${relativePath.editorPath}`} className="text-sm">
<p>
<Trans>Click here to add a recipient</Trans>
</p>
</Link>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : ( ) : (
@ -170,7 +179,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>
@ -182,7 +191,7 @@ export const EnvelopeEditorPageFields = () => {
{/* Field details section. */} {/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}> <AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && selectedField.type !== FieldType.SIGNATURE && ( {selectedField && (
<section> <section>
<Separator className="my-4" /> <Separator className="my-4" />
@ -192,6 +201,12 @@ export const EnvelopeEditorPageFields = () => {
</h3> </h3>
{match(selectedField.type) {match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => ( .with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm <EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined} value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}

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';
@ -24,7 +23,6 @@ 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 { 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';
@ -32,30 +30,34 @@ 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); 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({
data: {
title, title,
},
}); });
}} }}
placeholder={t`Envelope Title`} placeholder={t`Envelope Title`}
@ -132,7 +134,7 @@ 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} /> <DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
<EnvelopeEditorSettingsDialog <EnvelopeEditorSettingsDialog
trigger={ trigger={
@ -145,7 +147,11 @@ export default function EnvelopeEditorHeader() {
{isDocument && ( {isDocument && (
<> <>
<EnvelopeDistributeDialog <EnvelopeDistributeDialog
envelope={envelope} envelope={{
...envelope,
fields: editorFields.localFields,
}}
documentRootPath={relativePath.documentRootPath}
trigger={ trigger={
<Button size="sm"> <Button size="sm">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="mr-2 h-4 w-4" />
@ -168,10 +174,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();
@ -35,7 +33,7 @@ export const EnvelopeEditorPagePreview = () => {
return ( return (
<div className="relative flex h-full"> <div className="relative flex h-full">
<div className="flex w-full flex-col"> <div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */} {/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
@ -50,8 +48,23 @@ export const EnvelopeEditorPagePreview = () => {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* Coming soon section */}
<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">
<ConstructionIcon className="text-muted-foreground h-10 w-10" />
<h3 className="text-foreground text-sm font-semibold">
<Trans>Coming soon</Trans>
</h3>
<p className="text-muted-foreground text-sm">
<Trans>This feature is coming soon</Trans>
</p>
</div>
</div>
{/* Todo: Envelopes - Remove div after preview mode is implemented */}
<div className="hidden">
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" /> <FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -65,10 +78,11 @@ export const EnvelopeEditorPagePreview = () => {
)} )}
</div> </div>
</div> </div>
</div>
{/* Right Section - Form Fields Panel */} {/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && ( {currentEnvelopeItem && false && (
<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="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
{/* 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="mb-2 text-sm font-semibold text-gray-900">

View File

@ -14,7 +14,7 @@ import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react'; import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { prop, sortBy } from 'remeda'; import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod'; import { z } from 'zod';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -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();
@ -149,8 +148,7 @@ export const EnvelopeEditorRecipientForm = () => {
}, },
}); });
// Always show advanced settings if any recipient has auth options. const recipientHasAuthSettings = useMemo(() => {
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => { const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
@ -166,7 +164,7 @@ export const EnvelopeEditorRecipientForm = () => {
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined; return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]); }, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); const [showAdvancedSettings, setShowAdvancedSettings] = useState(recipientHasAuthSettings);
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
const { const {
@ -451,6 +449,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 +460,61 @@ export const EnvelopeEditorRecipientForm = () => {
return; return;
} }
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues); const formValueSigners = formValues.signers || [];
if (validatedFormValues.success) { // Remove the last signer if it's empty.
console.log('validatedFormValues', validatedFormValues); const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
if (i === formValueSigners.length - 1 && signer.email === '') {
return false;
}
return true;
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
...formValues,
signers: nonEmptyRecipients,
});
if (!validatedFormValues.success) {
return;
}
const { data } = validatedFormValues;
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
const hasAllowDictateNextSignerChanged =
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
const hasSignersChanged =
data.signers.length !== recipients.length ||
data.signers.some((signer) => {
const recipient = recipients.find((recipient) => recipient.id === signer.id);
if (!recipient) {
return true;
}
return (
signer.email !== recipient.email ||
signer.name !== recipient.name ||
signer.role !== recipient.role ||
signer.signingOrder !== recipient.signingOrder ||
!isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth)
);
});
if (hasSignersChanged) {
setRecipientsDebounced(validatedFormValues.data.signers); setRecipientsDebounced(validatedFormValues.data.signers);
}
// Todo: Envelopes - Need to save the other data as well if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
// setEnvelope updateEnvelope({
meta: {
signingOrder: validatedFormValues.data.signingOrder,
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
},
});
} }
}, [formValues]); }, [formValues]);
@ -508,18 +554,17 @@ 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 && ( {organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<Checkbox <Checkbox
id="showAdvancedRecipientSettings" id="showAdvancedRecipientSettings"
className="h-5 w-5"
checked={showAdvancedSettings} checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))} onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/> />
<label <label
className="text-muted-foreground ml-2 text-sm" className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="showAdvancedRecipientSettings" htmlFor="showAdvancedRecipientSettings"
> >
<Trans>Show advanced settings</Trans> <Trans>Show advanced settings</Trans>
@ -678,11 +723,14 @@ export const EnvelopeEditorRecipientForm = () => {
<motion.fieldset <motion.fieldset
data-native-id={signer.id} data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)} disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('grid grid-cols-10 items-end gap-2 pb-2', { className={cn('pb-2', {
'border-b pt-2': showAdvancedSettings, 'border-b pb-4':
'grid-cols-12 pr-3': isSigningOrderSequential, showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})} })}
> >
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && ( {isSigningOrderSequential && (
<FormField <FormField
control={form.control} control={form.control}
@ -690,7 +738,7 @@ export const EnvelopeEditorRecipientForm = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={cn( className={cn(
'col-span-1 mt-auto flex items-center gap-x-1 space-y-0', 'mt-auto flex items-center gap-x-1 space-y-0',
{ {
'mb-6': 'mb-6':
form.formState.errors.signers?.[index] && form.formState.errors.signers?.[index] &&
@ -705,7 +753,7 @@ export const EnvelopeEditorRecipientForm = () => {
max={signers.length} max={signers.length}
data-testid="signing-order-input" data-testid="signing-order-input"
className={cn( className={cn(
'w-full text-center', 'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)} )}
{...field} {...field}
@ -735,12 +783,10 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.email`} name={`signers.${index}.email`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={cn('relative', { className={cn('relative w-full', {
'mb-6': 'mb-6':
form.formState.errors.signers?.[index] && form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email, !form.formState.errors.signers[index]?.email,
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})} })}
> >
{!showAdvancedSettings && index === 0 && ( {!showAdvancedSettings && index === 0 && (
@ -783,12 +829,10 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.name`} name={`signers.${index}.name`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={cn({ className={cn('w-full', {
'mb-6': 'mb-6':
form.formState.errors.signers?.[index] && form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name, !form.formState.errors.signers[index]?.name,
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})} })}
> >
{!showAdvancedSettings && index === 0 && ( {!showAdvancedSettings && index === 0 && (
@ -825,6 +869,57 @@ export const EnvelopeEditorRecipientForm = () => {
)} )}
/> />
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && {showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && ( organisation.organisationClaim.flags.cfr21 && (
<FormField <FormField
@ -832,11 +927,11 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.actionAuth`} name={`signers.${index}.actionAuth`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
className={cn('col-span-8', { className={cn('mt-2 w-full', {
'mb-6': 'mb-6':
form.formState.errors.signers?.[index] && form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth, !form.formState.errors.signers[index]?.actionAuth,
'col-span-10': isSigningOrderSequential, 'pl-6': isSigningOrderSequential,
})} })}
> >
<FormControl> <FormControl>
@ -856,60 +951,6 @@ export const EnvelopeEditorRecipientForm = () => {
)} )}
/> />
)} )}
<div className="col-span-2 flex gap-x-2">
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className={cn(
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
{
'mb-6': form.formState.errors.signers?.[index],
},
)}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</motion.fieldset> </motion.fieldset>
</div> </div>
)} )}

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) {
@ -358,7 +355,7 @@ export const EnvelopeEditorSettingsDialog = ({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset <fieldset
className="flex min-h-[45rem] w-full flex-col space-y-6 px-6 pt-6" className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
key={activeTab} key={activeTab}
> >

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,16 @@ 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,
editorFields,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -100,13 +105,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 +130,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 +152,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-4rem)] 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">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length} Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span> </span>
</h3> </h3>
@ -176,15 +192,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 +212,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 +232,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,
}}
documentRootPath={relativePath.documentRootPath}
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 +272,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 +306,17 @@ export default function EnvelopeEditor() {
} }
/> />
{/* Todo: Allow selecting which document to download and/or the original */} <EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" /> <DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans> <Trans>Download PDF</Trans>
</Button> </Button>
}
/>
<Button <Button
variant="ghost" variant="ghost"
@ -309,7 +338,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 +347,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 +355,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>
@ -339,18 +368,15 @@ export default function EnvelopeEditor() {
</div> </div>
{/* Main Content - Changes based on current step */} {/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto"> <AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
<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>
</div> </div>
</div>
); );
} }

View File

@ -20,16 +20,17 @@ 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 ${ title={typeof primaryText === 'string' ? primaryText : undefined}
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
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}
@ -39,8 +40,8 @@ export const EnvelopeItemSelector = ({
<div className="text-xs text-gray-500">{secondaryText}</div> <div className="text-xs text-gray-500">{secondaryText}</div>
</div> </div>
<div <div
className={cn('h-2 w-2 rounded-full', { className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-blue-500': isSelected, 'bg-green-500': isSelected,
})} })}
></div> ></div>
</button> </button>
@ -61,7 +62,7 @@ export const EnvelopeRendererFileSelector = ({
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
return ( return (
<div className={cn('flex h-fit space-x-2 overflow-x-auto p-4', className)}> <div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
{envelopeItems.map((doc, i) => ( {envelopeItems.map((doc, i) => (
<EnvelopeItemSelector <EnvelopeItemSelector
key={doc.id} key={doc.id}

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) { const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender();
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext; const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
if (!page) { const { _className, scale } = pageContext;
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { currentEnvelopeItem, fields } = 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( 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,39 +57,29 @@ 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,
// color: getRecipientColorKey(field.recipientId), pageHeight: unscaledViewport.height,
color: 'purple', // Todo color: getRecipientColorKey(field.recipientId),
editable: false, editable: false,
mode: 'sign', mode: 'sign',
}); });
}; };
/** /**
* 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();
}; };
/** /**
* Render fields when they are added or removed from the localFields. * Render fields when they are added or removed
*/ */
useEffect(() => { useEffect(() => {
if (!pageLayer.current || !stage.current) { if (!pageLayer.current || !stage.current) {
@ -148,14 +92,12 @@ export default function EnvelopeGenericPageRenderer() {
group.name() === 'field-group' && group.name() === 'field-group' &&
!localPageFields.some((field) => field.id.toString() === group.id()) !localPageFields.some((field) => field.id.toString() === 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);
}); });
@ -167,14 +109,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,104 +1,78 @@
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 ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt={`${envelope.team.name}'s Logo`}
className="h-6 w-auto"
/>
) : (
<>
<BrandingLogo className="hidden h-6 w-auto md:block" />
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
</>
)}
</Link> </Link>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2"> <h1
<h1 className="whitespace-nowrap text-sm font-medium text-gray-600"> title={envelope.title}
className="text-foreground min-w-0 truncate text-base font-semibold md:hidden"
>
{envelope.title} {envelope.title}
</h1> </h1>
<Badge variant="secondary"> <Separator orientation="vertical" className="hidden h-6 md:block" />
<Trans>Approver</Trans>
<div className="hidden items-center space-x-2 md:flex">
<h1 className="text-foreground whitespace-nowrap text-sm font-medium">
{envelope.title}
</h1>
<Badge>
{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> </Badge>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> {/* 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"> <p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
<Plural <Plural
one="1 Field Remaining" one="1 Field Remaining"
@ -107,25 +81,59 @@ export const EnvelopeSignerHeader = () => {
/> />
</p> </p>
<DocumentSigningCompleteDialog <EnvelopeSignerCompleteDialog />
isSubmitting={isPending}
onSignatureComplete={handleOnCompleteClick}
documentTitle={envelope.title}
fields={recipientFieldsRemaining}
fieldsValidated={handleOnNextFieldClick}
recipient={recipient}
// Todo: Envelopes
allowDictateNextSigner={envelope.documentMeta.allowDictateNextSigner}
// defaultNextSigner={
// nextRecipient
// ? { name: nextRecipient.name, email: nextRecipient.email }
// : undefined
// }
// Todo: Envelopes - use
// buttonSize="sm"
/>
</div> </div>
{/* Mobile Actions button */}
<div className="flex-shrink-0 md:hidden">
<MobileDropdownMenu />
</div> </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 }),
[page, rotate, scale],
);
const localPageFields = useMemo(
() =>
recipientFields.filter(
(field) => (field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[recipientFields, pageContext.pageNumber],
); );
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
// Custom renderer from Konva examples. const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
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,21 +158,20 @@ 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,
value: checkedValues.map((v) => v.id),
}).finally(() => {
loadingSpinnerGroup.destroy(); loadingSpinnerGroup.destroy();
}); });
}) })
@ -201,12 +179,18 @@ export default function EnvelopeSignerPageRenderer() {
* 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

@ -15,7 +15,6 @@ export type ShareDocumentDownloadButtonProps = {
documentData: DocumentData; documentData: DocumentData;
}; };
// Todo: Envelopes - Support multiple item downloads.
export const ShareDocumentDownloadButton = ({ export const ShareDocumentDownloadButton = ({
title, title,
documentData, documentData,

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,6 +178,22 @@ export const DocumentsTableActionDropdown = ({
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{row.internalVersion === 2 ? (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
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}> <DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans> <Trans>Download</Trans>
@ -185,6 +203,8 @@ export const DocumentsTableActionDropdown = ({
<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,19 +143,30 @@ 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">
{envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider
envelope={envelope}
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
)}
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</CardContent>
</Card>
</EnvelopeRenderProvider>
</div>
) : (
<Card <Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7" className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
{envelope.internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
{envelope.status !== DocumentStatus.COMPLETED && ( {envelope.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields <DocumentReadOnlyFields
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)} fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
@ -168,12 +182,13 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
key={envelope.envelopeItems[0].id} key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData} documentData={envelope.envelopeItems[0].documentData}
/> />
</>
)}
</CardContent> </CardContent>
</Card> </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">

View File

@ -99,7 +99,11 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return ( return (
<EnvelopeEditorProvider initialEnvelope={envelope}> <EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider envelope={envelope}> <EnvelopeRenderProvider
envelope={envelope}
fields={envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
<EnvelopeEditor /> <EnvelopeEditor />
</EnvelopeRenderProvider> </EnvelopeRenderProvider>
</EnvelopeEditorProvider> </EnvelopeEditorProvider>

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,19 +166,30 @@ 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">
{envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider
envelope={envelope}
fields={envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
)}
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</CardContent>
</Card>
</EnvelopeRenderProvider>
</div>
) : (
<Card <Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7" className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
{envelope.internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
<DocumentReadOnlyFields <DocumentReadOnlyFields
fields={readOnlyFields} fields={readOnlyFields}
showFieldStatus={false} showFieldStatus={false}
@ -190,12 +204,13 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
key={envelope.envelopeItems[0].id} key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData} documentData={envelope.envelopeItems[0].documentData}
/> />
</>
)}
</CardContent> </CardContent>
</Card> </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 +238,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

@ -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,6 +190,9 @@ export default function DirectTemplatePage() {
recipient={directTemplateRecipient} recipient={directTemplateRecipient}
user={user} user={user}
> >
<>
{sessionData?.user && <AuthenticatedHeader />}
<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">
<h1 <h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl" className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@ -118,7 +214,44 @@ export default function DirectTemplatePage() {
template={template} template={template}
/> />
</div> </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

@ -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": "*",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -30,4 +30,6 @@ server.use(
const handler = handle(build, server); const handler = handle(build, server);
serve({ fetch: handler.fetch, port: 3000 }); const port = parseInt(process.env.PORT || '3000', 10);
serve({ fetch: handler.fetch, port });

View File

@ -21,7 +21,7 @@ export default defineConfig({
}, },
}, },
server: { server: {
port: 3000, port: parseInt(process.env.PORT || '3000', 10),
strictPort: true, strictPort: true,
}, },
plugins: [ plugins: [
@ -85,6 +85,7 @@ export default defineConfig({
'nodemailer', 'nodemailer',
/playwright/, /playwright/,
'@playwright/browser-chromium', '@playwright/browser-chromium',
'skia-canvas',
], ],
}, },
}, },

2
package-lock.json generated
View File

@ -91,7 +91,7 @@
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "1.13.1", "version": "1.13.1",
"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": "*",

View File

@ -20,12 +20,12 @@ import {
getEnvelopeWhereInput, getEnvelopeWhereInput,
} from '@documenso/lib/server-only/envelope/get-envelope-by-id'; } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field'; import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields'; import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient'; import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients'; import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients'; import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
@ -1285,7 +1285,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
const updatedRecipient = await updateDocumentRecipients({ const updatedRecipient = await updateEnvelopeRecipients({
userId: user.id, userId: user.id,
teamId: team.id, teamId: team.id,
id: { id: {
@ -1336,7 +1336,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}, },
}); });
const deletedRecipient = await deleteDocumentRecipient({ const deletedRecipient = await deleteEnvelopeRecipient({
userId: user.id, userId: user.id,
teamId: team.id, teamId: team.id,
recipientId: Number(recipientId), recipientId: Number(recipientId),
@ -1634,10 +1634,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
const { fields } = await updateDocumentFields({ const { fields } = await updateEnvelopeFields({
userId: user.id, userId: user.id,
teamId: team.id, teamId: team.id,
documentId: legacyDocumentId, id: {
type: 'documentId',
id: legacyDocumentId,
},
fields: [ fields: [
{ {
id: Number(fieldId), id: Number(fieldId),

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,7 +268,9 @@ 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
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
.evaluate((e) => {
if (e instanceof HTMLInputElement) { if (e instanceof HTMLInputElement) {
e.click(); e.click();
} }
@ -278,7 +280,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
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,7 +369,9 @@ 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
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
.evaluate((e) => {
if (e instanceof HTMLInputElement) { if (e instanceof HTMLInputElement) {
e.click(); e.click();
} }
@ -377,7 +381,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
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

@ -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

@ -50,6 +50,7 @@ type UseEditorFieldsResponse = {
// Field operations // Field operations
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField; addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
setFieldId: (formId: string, id: number) => void;
removeFieldsByFormId: (formIds: string[]) => void; removeFieldsByFormId: (formIds: string[]) => void;
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void; updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField; duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
@ -123,7 +124,6 @@ export const useEditorFields = ({
} }
if (bypassCheck) { if (bypassCheck) {
console.log(3);
setSelectedFieldFormId(formId); setSelectedFieldFormId(formId);
return; return;
} }
@ -136,6 +136,7 @@ export const useEditorFields = ({
const field: TLocalField = { const field: TLocalField = {
...fieldData, ...fieldData,
formId: nanoid(12), formId: nanoid(12),
...restrictFieldPosValues(fieldData),
}; };
append(field); append(field);
@ -160,12 +161,31 @@ export const useEditorFields = ({
[localFields, remove, triggerFieldsUpdate], [localFields, remove, triggerFieldsUpdate],
); );
const setFieldId = (formId: string, id: number) => {
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, {
...localFields[index],
id,
});
}
};
const updateFieldByFormId = useCallback( const updateFieldByFormId = useCallback(
(formId: string, updates: Partial<TLocalField>) => { (formId: string, updates: Partial<TLocalField>) => {
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();
} }
}, },
@ -261,6 +281,7 @@ export const useEditorFields = ({
// Field operations // Field operations
addField, addField,
setFieldId,
removeFieldsByFormId, removeFieldsByFormId,
updateFieldByFormId, updateFieldByFormId,
duplicateField, duplicateField,
@ -279,3 +300,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,15 @@ 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 editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
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 +118,9 @@ export const EnvelopeEditorProvider = ({
setAutosaveError(false); setAutosaveError(false);
}, },
onError: (error) => { onError: (err) => {
console.error(err);
setAutosaveError(true); setAutosaveError(true);
toast({ toast({
@ -122,7 +136,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 +151,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({
@ -166,63 +189,65 @@ export const EnvelopeEditorProvider = ({
triggerSave: setFieldsDebounced, triggerSave: setFieldsDebounced,
flush: setFieldsAsync, flush: setFieldsAsync,
isPending: isFieldsMutationPending, isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (fields: TLocalField[]) => { } = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
await envelopeFieldSetMutationQuery.mutateAsync({ const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id, envelopeId: envelope.id,
envelopeType: envelope.type, envelopeType: envelope.type,
fields, fields: localFields,
}); });
}, 1000);
// Insert the IDs into the local fields.
envelopeFields.fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
}, 2000);
const { const {
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);
}; };
const editorFields = useEditorFields({
envelope,
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 +259,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 +296,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 +322,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 +330,8 @@ export const EnvelopeEditorProvider = ({
autosaveError, autosaveError,
flushAutosave, flushAutosave,
isAutosaving, isAutosaving,
relativePath,
syncEnvelope,
}} }}
> >
{children} {children}

View File

@ -3,6 +3,9 @@ import React from 'react';
import type { DocumentData } from '@prisma/client'; import type { DocumentData } from '@prisma/client';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope'; import type { TEnvelope } from '../../types/envelope';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
@ -23,6 +26,7 @@ type EnvelopeRenderProviderValue = {
currentEnvelopeItem: EnvelopeRenderItem | null; currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void; setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields']; fields: TEnvelope['fields'];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
}; };
interface EnvelopeRenderProviderProps { interface EnvelopeRenderProviderProps {
@ -35,6 +39,13 @@ interface EnvelopeRenderProviderProps {
* Only pass if the CustomRenderer you are passing in wants fields. * Only pass if the CustomRenderer you are passing in wants fields.
*/ */
fields?: TEnvelope['fields']; fields?: TEnvelope['fields'];
/**
* Optional recipient IDs used to determine the color of the fields.
*
* Only required for generic page renderers.
*/
recipientIds?: number[];
} }
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null); const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
@ -56,6 +67,7 @@ export const EnvelopeRenderProvider = ({
children, children,
envelope, envelope,
fields, fields,
recipientIds = [],
}: EnvelopeRenderProviderProps) => { }: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId. // Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({}); const [files, setFiles] = useState<Record<string, FileData>>({});
@ -132,6 +144,17 @@ export const EnvelopeRenderProvider = ({
} }
}, [envelope.envelopeItems]); }, [envelope.envelopeItems]);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
[recipientIds],
);
return ( return (
<EnvelopeRenderContext.Provider <EnvelopeRenderContext.Provider
value={{ value={{
@ -140,6 +163,7 @@ export const EnvelopeRenderProvider = ({
currentEnvelopeItem: currentItem, currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem, setCurrentEnvelopeItem,
fields: fields ?? [], fields: fields ?? [],
getRecipientColorKey,
}} }}
> >
{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';
@ -180,7 +191,7 @@ export const run = async ({
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,17 +364,98 @@ const decorateAndSignPdf = async ({
}); });
} }
// Handle V1 and legacy insertions.
if (envelope.internalVersion === 1) {
for (const field of envelopeItemFields) { for (const field of envelopeItemFields) {
if (field.inserted) { if (field.inserted) {
if (envelope.internalVersion === 2) { if (envelope.useLegacyFieldInsertion) {
await insertFieldInPDFV2(pdfDoc, field);
} else 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());
}
}
}
// Re-flatten the form to handle our checkbox and radio fields that // Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields // create native arcoFields

View File

@ -32,6 +32,7 @@ export const getDocumentWithDetailsById = async ({
return { return {
...envelope, ...envelope,
envelopeId: envelope.id, envelopeId: envelope.id,
internalVersion: envelope.internalVersion,
documentData: { documentData: {
...firstDocumentData, ...firstDocumentData,
envelopeItemId: envelope.envelopeItems[0].id, envelopeItemId: envelope.envelopeItems[0].id,

View File

@ -3,6 +3,7 @@ import {
DocumentSigningOrder, DocumentSigningOrder,
DocumentStatus, DocumentStatus,
EnvelopeType, EnvelopeType,
FieldType,
RecipientRole, RecipientRole,
SendStatus, SendStatus,
SigningStatus, SigningStatus,
@ -13,9 +14,13 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client'; import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import { import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload, mapEnvelopeToWebhookDocumentPayload,
@ -24,6 +29,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document'; import { isDocumentCompleted } from '../../utils/document';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -56,6 +62,7 @@ export const sendDocument = async ({
recipients: { recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }], orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
}, },
fields: true,
documentMeta: true, documentMeta: true,
envelopeItems: { envelopeItems: {
select: { select: {
@ -165,6 +172,78 @@ export const sendDocument = async ({
}); });
} }
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
// Auto insert radio and checkboxes that have default values.
if (envelope.internalVersion === 2) {
for (const field of envelope.fields) {
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toRadioCustomText(checkedItemIndex),
});
}
}
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: defaultValue,
});
}
}
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
});
let isValid = true;
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(
checkedIndices.length,
validation.value,
validationLength,
);
}
if (isValid) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toCheckboxCustomText(checkedIndices),
});
}
}
}
}
const updatedEnvelope = await prisma.$transaction(async (tx) => { const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) { if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
@ -177,6 +256,37 @@ export const sendDocument = async ({
}); });
} }
if (envelope.internalVersion === 2) {
const autoInsertedFields = await Promise.all(
fieldsToAutoInsert.map(async (field) => {
return await tx.field.update({
where: {
id: field.fieldId,
},
data: {
customText: field.customText,
inserted: true,
},
});
}),
);
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED,
envelopeId: envelope.id,
data: {
fields: autoInsertedFields.map((field) => ({
fieldId: field.id,
fieldType: field.type,
recipientId: field.recipientId,
})),
},
// Don't put metadata or user here since it's a system event.
}),
});
}
return await tx.envelope.update({ return await tx.envelope.update({
where: { where: {
id: envelope.id, id: envelope.id,

View File

@ -0,0 +1,144 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
export type GetRecipientEnvelopeByTokenOptions = {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
};
/**
* Get all the values and details for a direct template envelope that a recipient requires.
*
* Do not overexpose any information that the recipient should not have.
*/
export const getEnvelopeForDirectTemplateSigning = async ({
token,
userId,
accessAuth,
}: GetRecipientEnvelopeByTokenOptions): Promise<EnvelopeForSigningResponse> => {
if (!token) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Missing token',
});
}
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
status: DocumentStatus.DRAFT,
directLink: {
enabled: true,
token,
},
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
recipients: {
include: {
fields: {
include: {
signature: true,
},
},
},
orderBy: {
signingOrder: 'asc',
},
},
envelopeItems: {
include: {
documentData: true,
},
},
team: {
select: {
id: true,
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
directLink: true,
},
});
const recipient = (envelope?.recipients || []).find(
(r) => r.id === envelope?.directLink?.directTemplateRecipientId,
);
if (!envelope || !recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope has no items',
});
}
const documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: envelope.authOptions,
recipient,
userId,
authOptions: accessAuth,
});
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
? {
email: envelope.user.email,
name: envelope.user.name || '',
}
: {
email: envelope.team.teamEmail?.email || '',
name: envelope.team.name || '',
};
return ZEnvelopeForSigningResponse.parse({
envelope,
recipient: {
...recipient,
token: envelope.directLink?.token || '',
},
recipientSignature: null,
isRecipientsTurn: true,
isCompleted: false,
isRejected: false,
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
brandingEnabled: settings.brandingEnabled,
brandingLogo: settings.brandingLogo,
},
} satisfies EnvelopeForSigningResponse);
};

View File

@ -23,7 +23,7 @@ export type GetRecipientEnvelopeByTokenOptions = {
accessAuth?: TDocumentAuthMethods; accessAuth?: TDocumentAuthMethods;
}; };
const ZEnvelopeForSigningResponse = z.object({ export const ZEnvelopeForSigningResponse = z.object({
envelope: EnvelopeSchema.pick({ envelope: EnvelopeSchema.pick({
type: true, type: true,
status: true, status: true,
@ -31,6 +31,7 @@ const ZEnvelopeForSigningResponse = z.object({
secondaryId: true, secondaryId: true,
internalVersion: true, internalVersion: true,
completedAt: true, completedAt: true,
updatedAt: true,
deletedAt: true, deletedAt: true,
title: true, title: true,
authOptions: true, authOptions: true,

View File

@ -54,3 +54,54 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount), recipientHasAccount: Boolean(recipientUserAccount),
} as const; } as const;
}; };
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

@ -11,7 +11,7 @@ export type GetFieldByIdOptions = {
userId: number; userId: number;
teamId: number; teamId: number;
fieldId: number; fieldId: number;
envelopeType: EnvelopeType; envelopeType?: EnvelopeType;
}; };
export const getFieldById = async ({ export const getFieldById = async ({
@ -41,7 +41,7 @@ export const getFieldById = async ({
type: 'envelopeId', type: 'envelopeId',
id: field.envelopeId, id: field.envelopeId,
}, },
type: envelopeType, type: envelopeType ?? null,
userId, userId,
teamId, teamId,
}); });

View File

@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) { if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta); const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField( const errors = validateNumberField(
String(numberFieldParsedMeta.value), String(numberFieldParsedMeta.value),
numberFieldParsedMeta, numberFieldParsedMeta,
false,
); );
if (errors.length > 0) { if (errors.length > 0) {
@ -304,7 +306,10 @@ export const setFieldsForDocument = async ({
}); });
} }
return upsertedField; return {
...upsertedField,
formId: field.formId,
};
}), }),
); );
}); });
@ -338,17 +343,25 @@ export const setFieldsForDocument = async ({
} }
// Filter out fields that have been removed or have been updated. // Filter out fields that have been removed or have been updated.
const filteredFields = existingFields.filter((field) => { const mappedFilteredFields = existingFields
.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id); const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id); const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
return !isRemoved && !isUpdated; return !isRemoved && !isUpdated;
}); })
.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: undefined,
}));
const mappedPersistentFields = persistedFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: field?.formId,
}));
return { return {
fields: [...filteredFields, ...persistedFields].map((field) => fields: [...mappedFilteredFields, ...mappedPersistentFields],
mapFieldToLegacyField(field, envelope),
),
}; };
}; };
@ -357,6 +370,7 @@ export const setFieldsForDocument = async ({
*/ */
type FieldData = { type FieldData = {
id?: number | null; id?: number | null;
formId?: string;
envelopeItemId: string; envelopeItemId: string;
type: FieldType; type: FieldType;
recipientId: number; recipientId: number;

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