mirror of
https://github.com/documenso/documenso.git
synced 2026-07-09 12:35:04 +10:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 142444835f | |||
| 3ba2dd0613 | |||
| 47bdcd833f | |||
| 03eb6af69a |
@@ -27,9 +27,45 @@
|
||||
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 {
|
||||
:root {
|
||||
--font-sans: 'Inter';
|
||||
--font-signature: 'Caveat';
|
||||
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
@@ -61,8 +62,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||
};
|
||||
onDistribute?: () => Promise<void>;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -84,7 +86,11 @@ export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
|
||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||
|
||||
export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => {
|
||||
export const EnvelopeDistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
onDistribute,
|
||||
}: EnvelopeDistributeDialogProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const recipients = envelope.recipients;
|
||||
@@ -127,22 +133,36 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
||||
|
||||
const distributionMethod = watch('meta.distributionMethod');
|
||||
|
||||
const everySignerHasSignature = useMemo(
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
() =>
|
||||
envelope.recipients
|
||||
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
|
||||
.every((recipient) =>
|
||||
envelope.fields.some(
|
||||
envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER &&
|
||||
!envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
[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) => {
|
||||
try {
|
||||
await distributeEnvelope({ envelopeId: envelope.id, meta });
|
||||
|
||||
await onDistribute?.();
|
||||
|
||||
toast({
|
||||
title: t`Envelope distributed`,
|
||||
description: t`Your envelope has been distributed successfully.`,
|
||||
@@ -178,7 +198,7 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
||||
<Trans>Recipients will be able to sign the document once sent</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{everySignerHasSignature ? (
|
||||
{!invalidEnvelopeCode ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
@@ -350,6 +370,8 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
||||
</div>
|
||||
) : (
|
||||
<ul className="text-muted-foreground divide-y">
|
||||
{/* Todo: Envelopes - I don't think this section shows up */}
|
||||
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -426,12 +448,24 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Some signers have not been assigned a signature field. Please assign at least 1
|
||||
signature field to each signer before proceeding.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
{match(invalidEnvelopeCode)
|
||||
.with('MISSING_RECIPIENTS', () => (
|
||||
<AlertDescription>
|
||||
<Trans>You need at least one recipient to send a document</Trans>
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
|
||||
documentData: DocumentData;
|
||||
};
|
||||
|
||||
type EnvelopeDownloadDialogProps = {
|
||||
envelopeId: string;
|
||||
envelopeStatus: DocumentStatus;
|
||||
envelopeItems?: EnvelopeItemToDownload[];
|
||||
|
||||
/**
|
||||
* The recipient token to download the document.
|
||||
*
|
||||
* If not provided, it will be assumed that the current user can access the document.
|
||||
*/
|
||||
token?: string;
|
||||
trigger: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeDownloadDialog = ({
|
||||
envelopeId,
|
||||
envelopeStatus,
|
||||
envelopeItems: initialEnvelopeItems,
|
||||
token,
|
||||
trigger,
|
||||
}: EnvelopeDownloadDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [isDownloadingState, setIsDownloadingState] = useState<{
|
||||
[envelopeItemIdAndVersion: string]: boolean;
|
||||
}>({});
|
||||
|
||||
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
|
||||
`${envelopeItemId}-${version}`;
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpc.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
|
||||
|
||||
const onDownload = async (
|
||||
envelopeItem: EnvelopeItemToDownload,
|
||||
version: 'original' | 'signed',
|
||||
) => {
|
||||
const { id: envelopeItemId } = envelopeItem;
|
||||
|
||||
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloadingState((prev) => ({
|
||||
...prev,
|
||||
[generateDownloadKey(envelopeItemId, version)]: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await getFile({
|
||||
type: envelopeItem.documentData.type,
|
||||
data:
|
||||
version === 'signed'
|
||||
? envelopeItem.documentData.data
|
||||
: envelopeItem.documentData.initialData,
|
||||
});
|
||||
|
||||
const blob = new Blob([data], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
|
||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||
const filename = `${baseTitle}${suffix}`;
|
||||
|
||||
downloadFile({
|
||||
filename,
|
||||
data: blob,
|
||||
});
|
||||
|
||||
setIsDownloadingState((prev) => ({
|
||||
...prev,
|
||||
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
setIsDownloadingState((prev) => ({
|
||||
...prev,
|
||||
[generateDownloadKey(envelopeItemId, version)]: false,
|
||||
}));
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be downloaded at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Download Files</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Select the files you would like to download.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<>
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Skeleton className="h-4 w-28 rounded-lg" />
|
||||
<Skeleton className="h-4 w-20 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
envelopeItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<Trans>PDF Document</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'original')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Original</Trans>
|
||||
</Button>
|
||||
|
||||
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'signed')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Signed</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Todo: Envelopes - Download all button */}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -1,40 +1,15 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
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 { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
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>;
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
|
||||
export type SignFieldDropdownDialogProps = {
|
||||
fieldMeta: TDropdownFieldMeta;
|
||||
@@ -46,72 +21,25 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
|
||||
|
||||
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
||||
|
||||
const form = useForm<TSignFieldDropdownFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldDropdownFormSchema),
|
||||
defaultValues: {
|
||||
dropdown: fieldMeta.defaultValue,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Dropdown Field</Trans>
|
||||
</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
|
||||
control={form.control}
|
||||
name="dropdown"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue placeholder={t`Select an option`} />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{values.map((value, i) => (
|
||||
<SelectItem key={i} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CommandDialog
|
||||
position="start"
|
||||
dialogContentClassName="mt-4"
|
||||
open={true}
|
||||
onOpenChange={(value) => (!value ? call.end(null) : null)}
|
||||
>
|
||||
<CommandInput placeholder={t`Select an option`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading={t`Options`}>
|
||||
{values.map((value, i) => (
|
||||
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -29,20 +29,22 @@ const ZSignFieldEmailFormSchema = z.object({
|
||||
|
||||
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>(
|
||||
({ call }) => {
|
||||
({ call, placeholderEmail }) => {
|
||||
const form = useForm<TSignFieldEmailFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldEmailFormSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
email: placeholderEmail || '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Email</Trans>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Initials</Trans>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Name</Trans>
|
||||
|
||||
@@ -30,7 +30,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||
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;
|
||||
@@ -55,9 +55,7 @@ const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||
return foundRegex.test(value.toString());
|
||||
},
|
||||
{
|
||||
message: `Number needs to be formatted as ${numberFormat}`,
|
||||
// Todo: Envelopes
|
||||
// message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
||||
message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -86,7 +84,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Number Field</Trans>
|
||||
|
||||
@@ -50,7 +50,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Text Field</Trans>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } 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 { useNavigate } from 'react-router';
|
||||
import * as z from 'zod';
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||
} 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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -41,6 +45,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import type { Toast } 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(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
@@ -65,6 +75,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
export type TemplateUseDialogProps = {
|
||||
envelopeId: string;
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: Recipient[];
|
||||
@@ -77,6 +88,7 @@ export function TemplateUseDialog({
|
||||
recipients,
|
||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||
documentRootPath,
|
||||
envelopeId,
|
||||
templateId,
|
||||
templateSigningOrder,
|
||||
trigger,
|
||||
@@ -93,7 +105,7 @@ export function TemplateUseDialog({
|
||||
defaultValues: {
|
||||
distributeDocument: false,
|
||||
useCustomDocument: false,
|
||||
customDocumentData: undefined,
|
||||
customDocumentData: [],
|
||||
recipients: recipients
|
||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||
.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 } =
|
||||
trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
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 putPdfFile(data.customDocumentData);
|
||||
customDocumentDataId = customDocumentData.id;
|
||||
}
|
||||
const customDocumentData = await Promise.all(
|
||||
customFilesToUpload.map(async (item) => {
|
||||
const customDocumentData = await putPdfFile(item.data);
|
||||
|
||||
const { id } = await createDocumentFromTemplate({
|
||||
return {
|
||||
documentDataId: customDocumentData.id,
|
||||
envelopeItemId: item.envelopeItemId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { envelopeId } = await createDocumentFromTemplate({
|
||||
templateId,
|
||||
recipients: data.recipients,
|
||||
distributeDocument: data.distributeDocument,
|
||||
customDocumentDataId,
|
||||
customDocumentData,
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -140,7 +179,7 @@ export function TemplateUseDialog({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
let documentPath = `${documentRootPath}/${id}`;
|
||||
let documentPath = `${documentRootPath}/${envelopeId}`;
|
||||
|
||||
if (
|
||||
data.distributeDocument &&
|
||||
@@ -180,6 +219,18 @@ export function TemplateUseDialog({
|
||||
}
|
||||
}, [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 (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -384,7 +435,6 @@ export function TemplateUseDialog({
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="useCustomDocument"
|
||||
>
|
||||
{/* Todo: Envelopes - How will this work? */}
|
||||
<Trans>Upload custom document</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
@@ -406,116 +456,133 @@ export function TemplateUseDialog({
|
||||
/>
|
||||
|
||||
{form.watch('useCustomDocument') && (
|
||||
<div className="my-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customDocumentData"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="w-full space-y-4">
|
||||
<label
|
||||
className={cn(
|
||||
'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">
|
||||
{!field.value && (
|
||||
<>
|
||||
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
|
||||
<div className="mt-4 flex text-sm leading-6">
|
||||
<span className="text-muted-foreground relative">
|
||||
<Trans>
|
||||
<span className="text-primary font-semibold">
|
||||
Click to upload
|
||||
</span>{' '}
|
||||
or drag and drop
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
PDF files only
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{field.value && (
|
||||
<div className="text-muted-foreground space-y-1">
|
||||
<p className="text-sm font-medium">{field.value.name}</p>
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
<div className="my-4 space-y-2">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<SpinnerBox className="py-16" />
|
||||
) : (
|
||||
localCustomDocumentData.map((item, i) => (
|
||||
<FormField
|
||||
key={item.id}
|
||||
control={form.control}
|
||||
name={`customDocumentData.${i}.data`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
data-testid="template-use-dialog-file-input"
|
||||
className="absolute h-full w-full opacity-0"
|
||||
accept=".pdf,application/pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
field.onChange(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(msg`Please select a PDF file`),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(
|
||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||
),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
{field.value && (
|
||||
<div className="absolute right-2 top-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
field.onChange(undefined);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<div className="sr-only">
|
||||
<Trans>Clear file</Trans>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{field.value ? (
|
||||
<div>
|
||||
<Trans>
|
||||
Custom {(field.value.size / (1024 * 1024)).toFixed(2)}{' '}
|
||||
MB file
|
||||
</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<Trans>Default file</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{field.value ? (
|
||||
<div className="">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
field.onChange(undefined);
|
||||
}}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
const fileInput = document.getElementById(
|
||||
`template-use-dialog-file-input-${item.envelopeItemId}`,
|
||||
);
|
||||
|
||||
if (fileInput instanceof HTMLInputElement) {
|
||||
fileInput.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UploadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Upload</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id={`template-use-dialog-file-input-${item.envelopeItemId}`}
|
||||
className="hidden"
|
||||
accept=".pdf,application/pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
field.onChange(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(msg`Please select a PDF file`),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
file.size >
|
||||
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
|
||||
) {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(
|
||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||
),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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: '<=',
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@@ -7,11 +7,19 @@ import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import {
|
||||
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
ZCheckboxFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
checkboxValidationLength,
|
||||
checkboxValidationRules,
|
||||
checkboxValidationSigns,
|
||||
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -30,8 +38,8 @@ import {
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { checkboxValidationLength, checkboxValidationRules } from './constants';
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
@@ -44,6 +52,7 @@ const ZCheckboxFieldFormSchema = ZCheckboxFieldMeta.pick({
|
||||
required: true,
|
||||
values: true,
|
||||
readOnly: true,
|
||||
fontSize: true,
|
||||
})
|
||||
.extend({
|
||||
validationLength: z.coerce.number().optional(),
|
||||
@@ -90,6 +99,7 @@ export const EditorFieldCheckboxForm = ({
|
||||
values: value.values || [{ id: 1, checked: false, value: '' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,13 +109,17 @@ export const EditorFieldCheckboxForm = ({
|
||||
control,
|
||||
});
|
||||
|
||||
const addValue = () => {
|
||||
const addValue = (numberOfValues: number = 1) => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
const newId =
|
||||
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
|
||||
const currentMaxId = Math.max(...currentValues.map((val) => val.id));
|
||||
|
||||
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
|
||||
form.setValue('values', newValues);
|
||||
const newValues = Array.from({ length: numberOfValues }, (_, index) => ({
|
||||
id: currentMaxId + index + 1,
|
||||
checked: false,
|
||||
value: '',
|
||||
}));
|
||||
|
||||
form.setValue('values', [...currentValues, ...newValues]);
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
@@ -132,10 +146,34 @@ export const EditorFieldCheckboxForm = ({
|
||||
}
|
||||
}, [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 (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="direction"
|
||||
@@ -202,7 +240,25 @@ export const EditorFieldCheckboxForm = ({
|
||||
<FormControl>
|
||||
<Select
|
||||
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">
|
||||
<SelectValue placeholder={t`Pick a number`} />
|
||||
@@ -239,7 +295,7 @@ export const EditorFieldCheckboxForm = ({
|
||||
<Trans>Checkbox values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={addValue}>
|
||||
<button type="button" onClick={() => addValue()}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -285,6 +341,16 @@ export const EditorFieldCheckboxForm = ({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!isValidationRuleMetForPreselectedValues && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
The preselected values will be ignored unless they meet the validation criteria.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -8,7 +8,10 @@ import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
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 {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -28,56 +31,50 @@ import {
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZDropdownFieldFormSchema = z
|
||||
.object({
|
||||
defaultValue: z.string().optional(),
|
||||
values: z
|
||||
.object({
|
||||
value: z.string().min(1, {
|
||||
message: msg`Option value cannot be empty`.id,
|
||||
}),
|
||||
})
|
||||
.array()
|
||||
.min(1, {
|
||||
message: msg`Dropdown must have at least one option`.id,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Todo: Envelopes - This doesn't work.
|
||||
console.log({
|
||||
data,
|
||||
});
|
||||
const ZDropdownFieldFormSchema = z.object({
|
||||
defaultValue: z.string().optional(),
|
||||
values: z
|
||||
.object({
|
||||
value: z.string().min(1, {
|
||||
message: msg`Option value cannot be empty`.id,
|
||||
}),
|
||||
})
|
||||
.array()
|
||||
.min(1, {
|
||||
message: msg`Dropdown must have at least one option`.id,
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
const seen = new Map<string, number[]>(); // value → indices
|
||||
|
||||
if (data) {
|
||||
const values = data.map((item) => item.value);
|
||||
return new Set(values).size === values.length;
|
||||
values.forEach((item, index) => {
|
||||
const key = item.value;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, []);
|
||||
}
|
||||
seen.get(key)!.push(index);
|
||||
});
|
||||
|
||||
for (const [key, indices] of seen) {
|
||||
if (indices.length > 1 && key.trim() !== '') {
|
||||
for (const i of indices) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Duplicate values are not allowed`.id,
|
||||
path: [i, 'value'],
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Duplicate values are not allowed',
|
||||
},
|
||||
),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Default value must be one of the available options
|
||||
if (data.defaultValue && data.values) {
|
||||
return data.values.some((item) => item.value === data.defaultValue);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Default value must be one of the available options',
|
||||
path: ['defaultValue'],
|
||||
},
|
||||
);
|
||||
}),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
fontSize: z.number().optional(),
|
||||
});
|
||||
|
||||
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
|
||||
|
||||
@@ -102,6 +99,7 @@ export const EditorFieldDropdownForm = ({
|
||||
values: value.values || [{ value: 'Option 1' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,7 +109,20 @@ export const EditorFieldDropdownForm = ({
|
||||
|
||||
const addValue = () => {
|
||||
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);
|
||||
};
|
||||
@@ -127,6 +138,10 @@ export const EditorFieldDropdownForm = ({
|
||||
newValues.splice(index, 1);
|
||||
|
||||
form.setValue('values', newValues);
|
||||
|
||||
if (form.getValues('defaultValue') === newValues[index].value) {
|
||||
form.setValue('defaultValue', undefined);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,19 +155,13 @@ export const EditorFieldDropdownForm = ({
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
const { formState } = form;
|
||||
|
||||
useEffect(() => {
|
||||
console.log({
|
||||
errors: formState.errors,
|
||||
formValues,
|
||||
});
|
||||
}, [formState, formState.errors, formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
{/* Todo: Envelopes This is buggy. */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultValue"
|
||||
@@ -163,20 +172,25 @@ export const EditorFieldDropdownForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
value={field.value ?? '-1'}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Default Value`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(formValues.values || []).map((item, index) => (
|
||||
<SelectItem key={index} value={item.value || ''}>
|
||||
{item.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(formValues.values || [])
|
||||
.filter((item) => item.value)
|
||||
.map((item, index) => (
|
||||
<SelectItem key={index} value={item.value || ''}>
|
||||
{item.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Default Value</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@@ -130,6 +130,12 @@ export const EditorFieldNumberForm = ({
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<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} />
|
||||
|
||||
<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">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
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 { 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 { 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZRadioFieldFormSchema = z
|
||||
.object({
|
||||
label: z.string().optional(),
|
||||
values: z
|
||||
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
|
||||
.array()
|
||||
.min(1)
|
||||
.optional(),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// There cannot be more than one checked option
|
||||
if (data.values) {
|
||||
const checkedValues = data.values.filter((option) => option.checked);
|
||||
return checkedValues.length <= 1;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'There cannot be more than one checked option',
|
||||
path: ['values'],
|
||||
},
|
||||
);
|
||||
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
|
||||
label: true,
|
||||
direction: true,
|
||||
values: true,
|
||||
required: true,
|
||||
readOnly: true,
|
||||
fontSize: true,
|
||||
}).refine(
|
||||
(data) => {
|
||||
// There cannot be more than one checked option
|
||||
if (data.values) {
|
||||
const checkedValues = data.values.filter((option) => option.checked);
|
||||
return checkedValues.length <= 1;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'There cannot be more than one checked option',
|
||||
path: ['values'],
|
||||
},
|
||||
);
|
||||
|
||||
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||
|
||||
@@ -53,9 +68,12 @@ export type EditorFieldRadioFormProps = {
|
||||
export const EditorFieldRadioForm = ({
|
||||
value = {
|
||||
type: 'radio',
|
||||
direction: 'vertical',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldRadioFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TRadioFieldFormSchema>({
|
||||
resolver: zodResolver(ZRadioFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
@@ -64,6 +82,8 @@ export const EditorFieldRadioForm = ({
|
||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
direction: value.direction || 'vertical',
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,7 +127,37 @@ export const EditorFieldRadioForm = ({
|
||||
return (
|
||||
<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} />
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TSignatureFieldMeta,
|
||||
ZSignatureFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
|
||||
|
||||
const ZSignatureFieldFormSchema = ZSignatureFieldMeta.pick({
|
||||
fontSize: true,
|
||||
});
|
||||
|
||||
type TSignatureFieldFormSchema = z.infer<typeof ZSignatureFieldFormSchema>;
|
||||
|
||||
type EditorFieldSignatureFormProps = {
|
||||
value: TSignatureFieldMeta | undefined;
|
||||
onValueChange: (value: TSignatureFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldSignatureForm = ({
|
||||
value = {
|
||||
type: 'signature',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldSignatureFormProps) => {
|
||||
const form = useForm<TSignatureFieldFormSchema>({
|
||||
resolver: zodResolver(ZSignatureFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZSignatureFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'signature',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,10 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
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 {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -69,7 +72,7 @@ export const EditorFieldTextForm = ({
|
||||
placeholder: value.placeholder || '',
|
||||
text: value.text || '',
|
||||
characterLimit: value.characterLimit || 0,
|
||||
fontSize: value.fontSize || 14,
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
@@ -98,6 +101,12 @@ export const EditorFieldTextForm = ({
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<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
|
||||
control={form.control}
|
||||
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">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ export const SubscriptionClaimForm = ({
|
||||
name: subscriptionClaim.name,
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
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>
|
||||
<FormLabel>
|
||||
<Trans>Feature Flags</Trans>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+149
-124
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
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 { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -18,7 +18,9 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
@@ -45,6 +47,7 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
onSignatureComplete: (
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
directRecipient?: { name: string; email: string },
|
||||
) => void | Promise<void>;
|
||||
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||
disabled?: boolean;
|
||||
@@ -53,6 +56,12 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
directTemplatePayload?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
buttonSize?: 'sm' | 'lg';
|
||||
position?: 'start' | 'end' | 'center';
|
||||
};
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
@@ -63,6 +72,13 @@ const ZNextSignerFormSchema = z.object({
|
||||
|
||||
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 = ({
|
||||
isSubmitting,
|
||||
documentTitle,
|
||||
@@ -72,15 +88,19 @@ export const DocumentSigningCompleteDialog = ({
|
||||
recipient,
|
||||
disabled = false,
|
||||
allowDictateNextSigner = false,
|
||||
directTemplatePayload,
|
||||
defaultNextSigner,
|
||||
buttonSize = 'lg',
|
||||
position,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||
|
||||
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
|
||||
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
||||
|
||||
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
|
||||
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const form = useForm<TNextSignerFormSchema>({
|
||||
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 completionRequires2FA = useMemo(
|
||||
@@ -109,12 +137,23 @@ export const DocumentSigningCompleteDialog = ({
|
||||
});
|
||||
}
|
||||
|
||||
setIsEditingNextSigner(false);
|
||||
setShowDialog(open);
|
||||
};
|
||||
|
||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||
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
|
||||
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||
setShowTwoFactorForm(true);
|
||||
@@ -126,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
? { name: data.name, email: data.email }
|
||||
: undefined;
|
||||
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions);
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||
} catch (error) {
|
||||
const err = AppError.parseError(error);
|
||||
|
||||
@@ -152,21 +191,19 @@ export const DocumentSigningCompleteDialog = ({
|
||||
void form.handleSubmit(onFormSubmit)();
|
||||
};
|
||||
|
||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="lg"
|
||||
size={buttonSize}
|
||||
onClick={fieldsValidated}
|
||||
loading={isSubmitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
{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.VIEWER }, () => (
|
||||
<Trans>Mark as viewed</Trans>
|
||||
@@ -176,106 +213,98 @@ export const DocumentSigningCompleteDialog = ({
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogContent position={position}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete viewing the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete signing the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete approving the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete assisting the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.CC, () => null)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
|
||||
</div>
|
||||
|
||||
{!showTwoFactorForm && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
<DialogTitle>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{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()}
|
||||
<>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
{directTemplatePayload && !directTemplatePayload.email && (
|
||||
<Form {...directRecipientForm}>
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
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>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={directRecipientForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Your Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder={t`Enter your email`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allowDictateNextSigner && (
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||
>
|
||||
<Trans>Update Recipient</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingNextSigner && (
|
||||
<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">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -283,13 +312,13 @@ export const DocumentSigningCompleteDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
<Trans>Next Recipient Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's name"
|
||||
placeholder={t`Enter the next signer's name`}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -304,14 +333,14 @@ export const DocumentSigningCompleteDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
<Trans>Next Recipient Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's email"
|
||||
placeholder={t`Enter the next signer's email`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -319,17 +348,14 @@ export const DocumentSigningCompleteDialog = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentSigningDisclosure className="mt-4" />
|
||||
<DocumentSigningDisclosure />
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
@@ -339,8 +365,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!isComplete || !isNextSignerValid}
|
||||
disabled={!isComplete}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{match(recipient.role)
|
||||
@@ -351,11 +376,11 @@ export const DocumentSigningCompleteDialog = ({
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTwoFactorForm && (
|
||||
|
||||
+123
@@ -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>
|
||||
);
|
||||
};
|
||||
+111
-69
@@ -1,16 +1,20 @@
|
||||
import { lazy } from 'react';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
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 { Button } from '@documenso/ui/primitives/button';
|
||||
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 { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-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 EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||
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';
|
||||
|
||||
const EnvelopeSignerPageRenderer = lazy(
|
||||
@@ -33,15 +39,30 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
isDirectTemplate,
|
||||
envelope,
|
||||
recipient,
|
||||
recipientFields,
|
||||
recipientFieldsRemaining,
|
||||
showPendingFieldTooltip,
|
||||
requiredRecipientFields,
|
||||
selectedAssistantRecipientFields,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
/**
|
||||
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||
*
|
||||
* Includes both optional and required fields.
|
||||
*/
|
||||
const remainingFields = useMemo(() => {
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
return selectedAssistantRecipientFields.filter((field) => !field.inserted);
|
||||
}
|
||||
|
||||
return recipientFields.filter((field) => !field.inserted);
|
||||
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-gray-50">
|
||||
<div className="dark:bg-background min-h-screen w-screen bg-gray-50">
|
||||
<SignFieldEmailDialog.Root />
|
||||
<SignFieldTextDialog.Root />
|
||||
<SignFieldNumberDialog.Root />
|
||||
@@ -49,19 +70,29 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
<SignFieldInitialsDialog.Root />
|
||||
<SignFieldDropdownDialog.Root />
|
||||
<SignFieldSignatureDialog.Root />
|
||||
<SignFieldCheckboxDialog.Root />
|
||||
|
||||
<EnvelopeSignerHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-73px)] w-screen">
|
||||
{/* 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">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
|
||||
<Trans>Sign Document</Trans>
|
||||
<h3 className="text-foreground flex items-end justify-between text-sm 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)}
|
||||
|
||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
||||
<Trans>{recipientFieldsRemaining.length} fields remaining</Trans>
|
||||
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||
<Plural
|
||||
value={recipientFieldsRemaining.length}
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
/>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -71,7 +102,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
style={{
|
||||
width: `${(100 / recipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -84,31 +115,50 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<div className="space-y-3 px-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
{!isDirectTemplate && (
|
||||
<div className="space-y-3 px-4">
|
||||
<h4 className="text-foreground text-sm font-semibold">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
<div className="w-full">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
|
||||
<div className="w-full">
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={envelope.id}
|
||||
token={recipient.token}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:text-destructive w-full justify-start"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</Button>
|
||||
|
||||
{/* Todo: Envelopes */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:text-destructive w-full justify-start"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer of left sidebar. */}
|
||||
<div className="mt-auto px-4">
|
||||
@@ -121,47 +171,34 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Changes based on current step */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
<div className="flex h-fit space-x-2 overflow-x-auto p-4">
|
||||
{envelopeItems.map((doc, i) => (
|
||||
<EnvelopeItemSelector
|
||||
key={doc.id}
|
||||
number={i + 1}
|
||||
primaryText={doc.title}
|
||||
secondaryText={
|
||||
<Plural
|
||||
one="1 Field"
|
||||
other="# Fields"
|
||||
value={
|
||||
recipientFieldsRemaining.filter((field) => field.envelopeItemId === doc.id)
|
||||
.length
|
||||
}
|
||||
/>
|
||||
}
|
||||
isSelected={currentEnvelopeItem?.id === doc.id}
|
||||
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{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) => (
|
||||
<EnvelopeItemSelector
|
||||
key={doc.id}
|
||||
number={i + 1}
|
||||
primaryText={doc.title}
|
||||
secondaryText={
|
||||
<Plural
|
||||
one="1 Field"
|
||||
other="# Fields"
|
||||
value={
|
||||
remainingFields.filter((field) => field.envelopeItemId === doc.id).length
|
||||
}
|
||||
/>
|
||||
}
|
||||
isSelected={currentEnvelopeItem?.id === doc.id}
|
||||
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||
{currentEnvelopeItem ? (
|
||||
<PDFViewerKonvaLazy
|
||||
key={currentEnvelopeItem.id}
|
||||
@@ -175,6 +212,11 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile widget - Additional padding to allow users to scroll */}
|
||||
<div className="block pb-16 md:hidden">
|
||||
<DocumentSigningMobileWidget />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -39,12 +39,14 @@ export interface DocumentSigningRejectDialogProps {
|
||||
documentId: number;
|
||||
token: string;
|
||||
onRejected?: (reason: string) => void | Promise<void>;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DocumentSigningRejectDialog({
|
||||
documentId,
|
||||
token,
|
||||
onRejected,
|
||||
trigger,
|
||||
}: DocumentSigningRejectDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
@@ -108,9 +110,11 @@ export function DocumentSigningRejectDialog({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
{trigger ?? (
|
||||
<Button variant="outline">
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EnvelopeType,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
@@ -11,11 +12,17 @@ import {
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
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 { 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 type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||
|
||||
export type EnvelopeSigningContextValue = {
|
||||
isDirectTemplate: boolean;
|
||||
|
||||
fullName: string;
|
||||
setFullName: (_value: string) => void;
|
||||
email: string;
|
||||
@@ -32,7 +39,8 @@ export type EnvelopeSigningContextValue = {
|
||||
recipient: EnvelopeForSigningResponse['recipient'];
|
||||
recipientFieldsRemaining: Field[];
|
||||
recipientFields: Field[];
|
||||
selectedRecipientFields: Field[];
|
||||
requiredRecipientFields: Field[];
|
||||
selectedAssistantRecipientFields: Field[];
|
||||
nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
||||
otherRecipientCompletedFields: (Field & {
|
||||
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>;
|
||||
@@ -85,26 +93,31 @@ export const EnvelopeSigningProvider = ({
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocument,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
const isDirectTemplate = envelope.type === EnvelopeType.TEMPLATE;
|
||||
|
||||
const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (data) => {
|
||||
console.log('signEnvelopeField', data);
|
||||
|
||||
const newRecipientFields = envelopeData.recipient.fields.map((field) =>
|
||||
field.id === data.signedField.id ? data.signedField : field,
|
||||
);
|
||||
|
||||
setEnvelopeData((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: {
|
||||
...prev.recipient,
|
||||
fields: newRecipientFields,
|
||||
fields: prev.recipient.fields.map((field) =>
|
||||
field.id === data.signedField.id ? data.signedField : field,
|
||||
),
|
||||
},
|
||||
}));
|
||||
},
|
||||
@@ -148,6 +161,27 @@ export const EnvelopeSigningProvider = ({
|
||||
})(),
|
||||
);
|
||||
|
||||
/**
|
||||
* The fields that are still required to be signed by the actual recipient.
|
||||
*/
|
||||
const recipientFieldsRemaining = useMemo(() => {
|
||||
return envelopeData.recipient.fields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||
}, [envelopeData.recipient.fields]);
|
||||
|
||||
/**
|
||||
* All the required fields for the actual recipient.
|
||||
*/
|
||||
const requiredRecipientFields = useMemo(() => {
|
||||
return envelopeData.recipient.fields.filter((field) => isRequiredField(field));
|
||||
}, [envelopeData.recipient.fields]);
|
||||
|
||||
/**
|
||||
* All the fields for the actual recipient.
|
||||
*/
|
||||
const recipientFields = useMemo(() => {
|
||||
return envelopeData.recipient.fields;
|
||||
}, [envelopeData.recipient.fields]);
|
||||
|
||||
/**
|
||||
* Assistant recipients are those that have a signing order after the assistant.
|
||||
*/
|
||||
@@ -181,22 +215,8 @@ export const EnvelopeSigningProvider = ({
|
||||
return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null;
|
||||
}, [envelope.recipients, selectedAssistantRecipientId]);
|
||||
|
||||
/**
|
||||
* The fields that are still required to be signed by the current recipient.
|
||||
*/
|
||||
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);
|
||||
const selectedAssistantRecipientFields = useMemo(() => {
|
||||
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
||||
}, [recipientFields, selectedAssistantRecipient]);
|
||||
|
||||
/**
|
||||
@@ -244,6 +264,12 @@ export const EnvelopeSigningProvider = ({
|
||||
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
|
||||
console.log('insertField', fieldId, fieldValue);
|
||||
|
||||
// Set the field locally for direct templates.
|
||||
if (isDirectTemplate) {
|
||||
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
||||
return;
|
||||
}
|
||||
|
||||
await signEnvelopeField({
|
||||
token: envelopeData.recipient.token,
|
||||
fieldId,
|
||||
@@ -252,9 +278,67 @@ export const EnvelopeSigningProvider = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDirectTemplateFieldInsertion = (
|
||||
fieldId: number,
|
||||
fieldValue: TSignEnvelopeFieldValue,
|
||||
) => {
|
||||
const foundField = recipient.fields.find((field) => field.id === fieldId);
|
||||
|
||||
if (!foundField) {
|
||||
throw new Error('Not possible');
|
||||
}
|
||||
|
||||
const insertionValues = extractFieldInsertionValues({
|
||||
fieldValue,
|
||||
field: foundField,
|
||||
documentMeta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const updatedField = {
|
||||
...foundField,
|
||||
...insertionValues,
|
||||
};
|
||||
|
||||
if (fieldValue.type === FieldType.SIGNATURE) {
|
||||
const isBase64 = isBase64Image(fieldValue.value || '');
|
||||
|
||||
updatedField.signature = fieldValue.value
|
||||
? {
|
||||
signatureImageAsBase64: isBase64 ? fieldValue.value : null,
|
||||
typedSignature: isBase64 ? null : fieldValue.value,
|
||||
recipientId: recipient.id,
|
||||
created: new Date(),
|
||||
// Dummy IDs.
|
||||
id: 0,
|
||||
fieldId: 0,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
setEnvelopeData((prev) => ({
|
||||
...prev,
|
||||
envelope: {
|
||||
...prev.envelope,
|
||||
recipients: prev.envelope.recipients.map((r) =>
|
||||
r.id === recipient.id
|
||||
? {
|
||||
...r,
|
||||
fields: r.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||
}
|
||||
: r,
|
||||
),
|
||||
},
|
||||
recipient: {
|
||||
...prev.recipient,
|
||||
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeSigningContext.Provider
|
||||
value={{
|
||||
isDirectTemplate,
|
||||
fullName,
|
||||
setFullName,
|
||||
email,
|
||||
@@ -270,6 +354,7 @@ export const EnvelopeSigningProvider = ({
|
||||
recipient,
|
||||
recipientFieldsRemaining,
|
||||
recipientFields,
|
||||
requiredRecipientFields,
|
||||
nextRecipient,
|
||||
|
||||
otherRecipientCompletedFields,
|
||||
@@ -277,7 +362,7 @@ export const EnvelopeSigningProvider = ({
|
||||
assistantFields,
|
||||
setSelectedAssistantRecipientId,
|
||||
selectedAssistantRecipient,
|
||||
selectedRecipientFields,
|
||||
selectedAssistantRecipientFields,
|
||||
|
||||
signField,
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
@@ -24,6 +25,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentAttachmentsPopoverProps = {
|
||||
envelopeId: string;
|
||||
buttonClassName?: string;
|
||||
buttonSize?: 'sm' | 'default';
|
||||
};
|
||||
|
||||
const ZAttachmentFormSchema = z.object({
|
||||
@@ -33,7 +36,11 @@ const ZAttachmentFormSchema = z.object({
|
||||
|
||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||
|
||||
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
|
||||
export const DocumentAttachmentsPopover = ({
|
||||
envelopeId,
|
||||
buttonClassName,
|
||||
buttonSize,
|
||||
}: DocumentAttachmentsPopoverProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
@@ -118,7 +125,7 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
|
||||
<span>
|
||||
@@ -215,9 +222,6 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -230,6 +234,9 @@ export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPo
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -95,6 +95,10 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => 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.`);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -14,6 +14,8 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
|
||||
export type DocumentPageViewButtonProps = {
|
||||
envelope: TEnvelope;
|
||||
};
|
||||
@@ -59,6 +61,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
internalVersion: envelope.internalVersion,
|
||||
})
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
@@ -92,6 +95,20 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
</Link>
|
||||
</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 }, () => (
|
||||
<Button className="w-full" onClick={onDownloadClick}>
|
||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -146,17 +147,36 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{isComplete && (
|
||||
<DropdownMenuItem onClick={onDownloadClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</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 && (
|
||||
<DropdownMenuItem onClick={onDownloadClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||
|
||||
@@ -108,6 +108,10 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => 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.`);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -51,7 +52,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
);
|
||||
|
||||
const { quota, remaining, refreshLimits } = useLimits();
|
||||
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -69,6 +70,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
if (!user.emailVerified) {
|
||||
return msg`Verify your email to upload documents.`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remaining.documents, user.emailVerified, team]);
|
||||
|
||||
@@ -138,6 +140,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => 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.`);
|
||||
|
||||
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({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title:
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? t`Your document failed to upload.`
|
||||
: t`Your template failed to upload.`,
|
||||
title: t`Upload failed`,
|
||||
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
@@ -176,6 +193,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
type="envelope"
|
||||
maxFiles={maximumEnvelopeItemCount}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
+21
-6
@@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
}: EnvelopeEditorFieldDragDropProps) => {
|
||||
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -262,6 +262,10 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
const selectedRecipientColor = useMemo(() => {
|
||||
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
||||
}, [selectedRecipientId, getRecipientColorKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||
@@ -273,12 +277,23 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
onClick={() => setSelectedField(field.type)}
|
||||
onMouseDown={() => setSelectedField(field.type)}
|
||||
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
|
||||
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,
|
||||
{
|
||||
'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" />}
|
||||
@@ -291,9 +306,9 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||
// selectedSignerStyles?.base,
|
||||
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
|
||||
'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]',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
'dark:text-black/60': isFieldWithinBounds,
|
||||
|
||||
+88
-132
@@ -3,15 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import type { FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import type { Layer } from 'konva/lib/Layer';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||
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 { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
@@ -21,32 +18,16 @@ import {
|
||||
convertPixelToPercentage,
|
||||
} from '@documenso/lib/universal/field-renderer/field-renderer';
|
||||
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 { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
|
||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
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 { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const { envelope, 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 interactiveTransformer = useRef<Transformer | null>(null);
|
||||
|
||||
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
||||
@@ -54,10 +35,17 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||
|
||||
const viewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
@@ -68,44 +56,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
[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>) => {
|
||||
console.log('Field resized or moved');
|
||||
|
||||
@@ -120,6 +70,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const fieldGroup = event.target as Konva.Group;
|
||||
const fieldFormId = fieldGroup.id();
|
||||
|
||||
// Note: This values are scaled.
|
||||
const {
|
||||
width: fieldPixelWidth,
|
||||
height: fieldPixelHeight,
|
||||
@@ -130,7 +81,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
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
|
||||
const positionPercentX = (fieldX / pageWidth) * 100;
|
||||
@@ -165,8 +117,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (field: TLocalField) => {
|
||||
if (!pageLayer.current || !interactiveTransformer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
if (!pageLayer.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,7 +125,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const isFieldEditable =
|
||||
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||
|
||||
const { fieldGroup, isFirstRender } = renderField({
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.formId,
|
||||
@@ -183,8 +135,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
translations: getClientSideFieldTranslations(i18n),
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
color: getRecipientColorKey(field.recipientId),
|
||||
editable: isFieldEditable,
|
||||
mode: 'edit',
|
||||
@@ -210,24 +163,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
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);
|
||||
|
||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||
// Initialize snap guides layer
|
||||
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
||||
|
||||
// Add transformer for resizing and rotating.
|
||||
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
|
||||
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
|
||||
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
@@ -235,12 +178,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
|
||||
// Handle stage click to deselect.
|
||||
stage.current?.on('click', (e) => {
|
||||
currentStage.on('mousedown', (e) => {
|
||||
removePendingField();
|
||||
|
||||
if (e.target === stage.current) {
|
||||
setSelectedFields([]);
|
||||
pageLayer.current?.batchDraw();
|
||||
currentPageLayer.batchDraw();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -267,12 +210,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
setSelectedFields([e.target]);
|
||||
};
|
||||
|
||||
stage.current?.on('dragstart', onDragStartOrEnd);
|
||||
stage.current?.on('dragend', onDragStartOrEnd);
|
||||
stage.current?.on('transformstart', () => setIsFieldChanging(true));
|
||||
stage.current?.on('transformend', () => setIsFieldChanging(false));
|
||||
currentStage.on('dragstart', onDragStartOrEnd);
|
||||
currentStage.on('dragend', onDragStartOrEnd);
|
||||
currentStage.on('transformstart', () => setIsFieldChanging(true));
|
||||
currentStage.on('transformend', () => setIsFieldChanging(false));
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
currentPageLayer.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -284,7 +227,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
* - Selecting multiple 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({
|
||||
rotateEnabled: false,
|
||||
keepRatio: false,
|
||||
@@ -301,36 +247,39 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
},
|
||||
});
|
||||
|
||||
layer.add(transformer);
|
||||
currentPageLayer.add(transformer);
|
||||
|
||||
// Add selection rectangle.
|
||||
const selectionRectangle = new Konva.Rect({
|
||||
fill: 'rgba(24, 160, 251, 0.3)',
|
||||
visible: false,
|
||||
});
|
||||
layer.add(selectionRectangle);
|
||||
currentPageLayer.add(selectionRectangle);
|
||||
|
||||
let x1: number;
|
||||
let y1: number;
|
||||
let x2: number;
|
||||
let y2: number;
|
||||
|
||||
stage.on('mousedown touchstart', (e) => {
|
||||
currentStage.on('mousedown touchstart', (e) => {
|
||||
// do nothing if we mousedown on any shape
|
||||
if (e.target !== stage) {
|
||||
if (e.target !== currentStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerPosition = stage.getPointerPosition();
|
||||
const pointerPosition = currentStage.getPointerPosition();
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
x1 = pointerPosition.x;
|
||||
y1 = pointerPosition.y;
|
||||
x2 = pointerPosition.x;
|
||||
y2 = pointerPosition.y;
|
||||
console.log(`pointerPosition.x: ${pointerPosition.x}`);
|
||||
console.log(`pointerPosition.y: ${pointerPosition.y}`);
|
||||
|
||||
x1 = pointerPosition.x / scale;
|
||||
y1 = pointerPosition.y / scale;
|
||||
x2 = pointerPosition.x / scale;
|
||||
y2 = pointerPosition.y / scale;
|
||||
|
||||
selectionRectangle.setAttrs({
|
||||
x: x1,
|
||||
@@ -341,7 +290,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
});
|
||||
});
|
||||
|
||||
stage.on('mousemove touchmove', () => {
|
||||
currentStage.on('mousemove touchmove', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
@@ -349,14 +298,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
|
||||
selectionRectangle.moveToTop();
|
||||
|
||||
const pointerPosition = stage.getPointerPosition();
|
||||
const pointerPosition = currentStage.getPointerPosition();
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
x2 = pointerPosition.x;
|
||||
y2 = pointerPosition.y;
|
||||
x2 = pointerPosition.x / scale;
|
||||
y2 = pointerPosition.y / scale;
|
||||
|
||||
selectionRectangle.setAttrs({
|
||||
x: Math.min(x1, x2),
|
||||
@@ -366,7 +315,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
});
|
||||
});
|
||||
|
||||
stage.on('mouseup touchend', () => {
|
||||
currentStage.on('mouseup touchend', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
@@ -377,38 +326,41 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
selectionRectangle.visible(false);
|
||||
});
|
||||
|
||||
const stageFieldGroups = stage.find('.field-group') || [];
|
||||
const stageFieldGroups = currentStage.find('.field-group') || [];
|
||||
const box = selectionRectangle.getClientRect();
|
||||
const selectedFieldGroups = stageFieldGroups.filter(
|
||||
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
||||
);
|
||||
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.
|
||||
if (
|
||||
selectedFieldGroups.length === 0 &&
|
||||
canvasElement.current &&
|
||||
box.width > MIN_FIELD_WIDTH_PX &&
|
||||
box.height > MIN_FIELD_HEIGHT_PX &&
|
||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||
editorFields.selectedRecipient &&
|
||||
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
||||
) {
|
||||
const pendingFieldCreation = new Konva.Rect({
|
||||
name: 'pending-field-creation',
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
x: box.x / scale,
|
||||
y: box.y / scale,
|
||||
width: unscaledBoxWidth,
|
||||
height: unscaledBoxHeight,
|
||||
fill: 'rgba(24, 160, 251, 0.3)',
|
||||
});
|
||||
|
||||
layer.add(pendingFieldCreation);
|
||||
currentPageLayer.add(pendingFieldCreation);
|
||||
setPendingFieldCreation(pendingFieldCreation);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 (
|
||||
selectionRectangle.visible() &&
|
||||
@@ -419,7 +371,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
|
||||
// If empty area clicked, remove all selections
|
||||
if (e.target === stage) {
|
||||
if (e.target === stage.current) {
|
||||
setSelectedFields([]);
|
||||
return;
|
||||
}
|
||||
@@ -468,20 +420,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
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);
|
||||
});
|
||||
|
||||
// If it doesn't exist, render it.
|
||||
//
|
||||
|
||||
// Rerender the transformer
|
||||
interactiveTransformer.current?.forceUpdate();
|
||||
|
||||
@@ -555,15 +502,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
|
||||
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
||||
width: pixelWidth,
|
||||
height: pixelHeight,
|
||||
positionX: pixelX,
|
||||
positionY: pixelY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
});
|
||||
|
||||
editorFields.addField({
|
||||
@@ -597,7 +542,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
{selectedKonvaFieldGroups.length > 0 &&
|
||||
interactiveTransformer.current &&
|
||||
!isFieldChanging && (
|
||||
@@ -649,17 +597,23 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Todo: Envelopes - This will not overflow the page when close to edges */}
|
||||
{pendingFieldCreation && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
|
||||
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
|
||||
top:
|
||||
pendingFieldCreation.y() * scale +
|
||||
pendingFieldCreation.getClientRect().height +
|
||||
5 +
|
||||
'px',
|
||||
left:
|
||||
pendingFieldCreation.x() * scale +
|
||||
pendingFieldCreation.getClientRect().width / 2 +
|
||||
'px',
|
||||
transform: 'translateX(-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) => (
|
||||
<button
|
||||
@@ -673,13 +627,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
</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
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-5
@@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
||||
};
|
||||
|
||||
export const EnvelopeEditorPageFields = () => {
|
||||
export const EnvelopeEditorFieldsPage = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
@@ -109,7 +109,7 @@ export const EnvelopeEditorPageFields = () => {
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex justify-center">
|
||||
<div className="mt-4 flex justify-center p-4">
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
||||
) : (
|
||||
@@ -128,10 +128,10 @@ export const EnvelopeEditorPageFields = () => {
|
||||
|
||||
{/* Right Section - Form Fields Panel */}
|
||||
{currentEnvelopeItem && (
|
||||
<div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
|
||||
<div className="bg-background border-border sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l py-4">
|
||||
{/* Recipient selector section. */}
|
||||
<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>
|
||||
</h3>
|
||||
|
||||
@@ -170,7 +170,7 @@ export const EnvelopeEditorPageFields = () => {
|
||||
|
||||
{/* Add fields section. */}
|
||||
<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>
|
||||
</h3>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||
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 { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
@@ -32,30 +30,35 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
export default function EnvelopeEditorHeader() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
// Todo: Envelopes this probably won't work with embed? Maybe hide the back items when no team?
|
||||
|
||||
const rootPath = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
const {
|
||||
envelope,
|
||||
isDocument,
|
||||
isTemplate,
|
||||
updateEnvelope,
|
||||
autosaveError,
|
||||
relativePath,
|
||||
syncEnvelope,
|
||||
editorFields,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
|
||||
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 space-x-4">
|
||||
<Link to="/">
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<EnvelopeItemTitleInput
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
value={envelope.title}
|
||||
onChange={(title) => {
|
||||
updateEnvelope({
|
||||
title,
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder={t`Envelope Title`}
|
||||
@@ -132,7 +135,7 @@ export default function EnvelopeEditorHeader() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<DocumentAttachmentsPopover envelopeId={envelope.id} />
|
||||
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
|
||||
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
@@ -145,7 +148,11 @@ export default function EnvelopeEditorHeader() {
|
||||
{isDocument && (
|
||||
<>
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={envelope}
|
||||
envelope={{
|
||||
...envelope,
|
||||
fields: editorFields.localFields,
|
||||
}}
|
||||
onDistribute={syncEnvelope}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<SendIcon className="mr-2 h-4 w-4" />
|
||||
@@ -168,10 +175,11 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
{isTemplate && (
|
||||
<TemplateUseDialog
|
||||
envelopeId={envelope.id}
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
||||
recipients={envelope.recipients}
|
||||
documentRootPath={rootPath}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<Trans>Use Template</Trans>
|
||||
|
||||
-176
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
-16
@@ -1,7 +1,7 @@
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
|
||||
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 { 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';
|
||||
|
||||
const EnvelopeEditorPagePreviewRenderer = lazy(
|
||||
async () => import('./envelope-editor-page-preview-renderer'),
|
||||
);
|
||||
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
||||
|
||||
export const EnvelopeEditorPagePreview = () => {
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
@@ -50,19 +48,35 @@ export const EnvelopeEditorPagePreview = () => {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
<p className="text-foreground mt-1 text-sm">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Please upload a document to continue</Trans>
|
||||
{/* 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 ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
<p className="text-foreground mt-1 text-sm">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Please upload a document to continue</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+32
-6
@@ -75,7 +75,6 @@ const ZEnvelopeRecipientsForm = z.object({
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
// Todo: Envelopes - These aren't synced to the server
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
});
|
||||
@@ -83,7 +82,7 @@ const ZEnvelopeRecipientsForm = z.object({
|
||||
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
|
||||
|
||||
export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced } = useCurrentEnvelopeEditor();
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
@@ -451,6 +450,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
void form.trigger();
|
||||
}, [form]);
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
@@ -460,15 +461,39 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
||||
const formValueSigners = formValues.signers || [];
|
||||
|
||||
// Remove the last signer if it's empty.
|
||||
const recipients = formValueSigners.filter((signer, i) => {
|
||||
if (i === formValueSigners.length - 1 && signer.email === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
|
||||
...formValues,
|
||||
signers: recipients,
|
||||
});
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
console.log('validatedFormValues', validatedFormValues);
|
||||
|
||||
setRecipientsDebounced(validatedFormValues.data.signers);
|
||||
|
||||
// Todo: Envelopes - Need to save the other data as well
|
||||
// setEnvelope
|
||||
if (
|
||||
validatedFormValues.data.signingOrder !== envelope.documentMeta.signingOrder ||
|
||||
validatedFormValues.data.allowDictateNextSigner !==
|
||||
envelope.documentMeta.allowDictateNextSigner
|
||||
) {
|
||||
updateEnvelope({
|
||||
meta: {
|
||||
signingOrder: validatedFormValues.data.signingOrder,
|
||||
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
@@ -508,7 +533,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<CardContent>
|
||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||
<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 && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
@@ -876,6 +901,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
onValueChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole);
|
||||
field.onChange(value);
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
|
||||
@@ -215,7 +215,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
|
||||
|
||||
// Todo: Envelopes - Extract into provider.
|
||||
const envelopeHasBeenSent =
|
||||
envelope.type === EnvelopeType.DOCUMENT &&
|
||||
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
|
||||
@@ -302,8 +301,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
setActiveTab('general');
|
||||
}, [open, form]);
|
||||
|
||||
// Todo: Envelopes - Show error indicator if error is in different tab.
|
||||
|
||||
const selectedTab = tabs.find((tab) => tab.id === activeTab);
|
||||
|
||||
if (!selectedTab) {
|
||||
|
||||
+65
-13
@@ -7,12 +7,16 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import {
|
||||
useCurrentEnvelopeEditor,
|
||||
useDebounceFunction,
|
||||
} 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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
@@ -26,9 +30,9 @@ import {
|
||||
CardTitle,
|
||||
} from '@documenso/ui/primitives/card';
|
||||
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 { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
@@ -41,11 +45,13 @@ type LocalFile = {
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorPageUpload = () => {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useLingui();
|
||||
export const EnvelopeEditorUploadPage = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
|
||||
const { t } = useLingui();
|
||||
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
|
||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
||||
envelope.envelopeItems
|
||||
@@ -220,12 +226,56 @@ export const EnvelopeEditorPageUpload = () => {
|
||||
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 (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CardDescription>Add and configure multiple documents</CardDescription>
|
||||
<CardTitle>
|
||||
<Trans>Documents</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans>Add and configure multiple documents</Trans>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
@@ -233,9 +283,11 @@ export const EnvelopeEditorPageUpload = () => {
|
||||
onDrop={onFileDrop}
|
||||
allowMultiple
|
||||
className="pb-4 pt-6"
|
||||
disabled={!canItemsBeModified}
|
||||
disabledMessage={msg`Cannot upload items after the document has been sent`}
|
||||
disabled={dropzoneDisabledMessage !== null}
|
||||
disabledMessage={dropzoneDisabledMessage || undefined}
|
||||
disabledHeading={msg`Upload disabled`}
|
||||
maxFiles={maximumEnvelopeItemCount - localFiles.length}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
|
||||
{/* Uploaded Files List */}
|
||||
@@ -256,7 +308,7 @@ export const EnvelopeEditorPageUpload = () => {
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -282,7 +334,7 @@ export const EnvelopeEditorPageUpload = () => {
|
||||
<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 ? (
|
||||
<Trans>Uploading</Trans>
|
||||
) : localFile.isError ? (
|
||||
@@ -295,7 +347,7 @@ export const EnvelopeEditorPageUpload = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
{localFile.isUploading && (
|
||||
<div className="flex h-6 w-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-500" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -338,7 +390,7 @@ export const EnvelopeEditorPageUpload = () => {
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button asChild>
|
||||
<Link to={`/t/${team.url}/documents/${envelope.id}/edit?step=addFields`}>
|
||||
<Link to={`${relativePath.editorPath}?step=addFields`}>
|
||||
<Trans>Add Fields</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} 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 { Button } from '@documenso/ui/primitives/button';
|
||||
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 { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
|
||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-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 { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
|
||||
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
|
||||
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
|
||||
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
||||
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
||||
|
||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
|
||||
@@ -74,10 +73,17 @@ export default function EnvelopeEditor() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, isDocument, isTemplate, isAutosaving, flushAutosave } =
|
||||
useCurrentEnvelopeEditor();
|
||||
const {
|
||||
envelope,
|
||||
isDocument,
|
||||
isTemplate,
|
||||
isAutosaving,
|
||||
flushAutosave,
|
||||
relativePath,
|
||||
syncEnvelope,
|
||||
editorFields,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -100,13 +106,10 @@ export default function EnvelopeEditor() {
|
||||
return 'upload';
|
||||
});
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
const navigateToStep = (step: EnvelopeEditorStep) => {
|
||||
setCurrentStep(step);
|
||||
|
||||
flushAutosave();
|
||||
void flushAutosave();
|
||||
|
||||
if (!isStepLoading && isAutosaving) {
|
||||
setIsStepLoading(true);
|
||||
@@ -128,6 +131,18 @@ export default function EnvelopeEditor() {
|
||||
}
|
||||
};
|
||||
|
||||
// Watch the URL params and setStep if the step changes.
|
||||
useEffect(() => {
|
||||
const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id;
|
||||
|
||||
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
|
||||
|
||||
if (foundStep && foundStep.id !== currentStep) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
navigateToStep(foundStep.id as EnvelopeEditorStep);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAutosaving) {
|
||||
setIsStepLoading(false);
|
||||
@@ -138,20 +153,22 @@ export default function EnvelopeEditor() {
|
||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-gray-50">
|
||||
<div className="dark:bg-background h-screen w-screen bg-gray-50">
|
||||
<EnvelopeEditorHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-73px)] w-screen">
|
||||
{/* 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. */}
|
||||
<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>}
|
||||
|
||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
<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}
|
||||
</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -176,15 +193,17 @@ export default function EnvelopeEditor() {
|
||||
key={step.id}
|
||||
className={`cursor-pointer rounded-lg p-3 transition-colors ${
|
||||
isActive
|
||||
? 'border border-green-200 bg-green-50'
|
||||
: 'border border-gray-200 hover:bg-gray-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 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
|
||||
}`}
|
||||
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
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
|
||||
@@ -194,12 +213,14 @@ export default function EnvelopeEditor() {
|
||||
<div>
|
||||
<div
|
||||
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)}
|
||||
</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>
|
||||
@@ -212,12 +233,25 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<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>
|
||||
</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 && (
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={envelope}
|
||||
envelope={{
|
||||
...envelope,
|
||||
fields: editorFields.localFields,
|
||||
}}
|
||||
onDistribute={syncEnvelope}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<SendIcon className="mr-2 h-4 w-4" />
|
||||
@@ -239,16 +273,6 @@ export default function EnvelopeEditor() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Todo: Envelopes */}
|
||||
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Save as Template
|
||||
@@ -283,11 +307,17 @@ export default function EnvelopeEditor() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -309,7 +339,7 @@ export default function EnvelopeEditor() {
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={async () => {
|
||||
await navigate(documentsPath);
|
||||
await navigate(relativePath.documentRootPath);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -318,7 +348,7 @@ export default function EnvelopeEditor() {
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={async () => {
|
||||
await navigate(templatesPath);
|
||||
await navigate(relativePath.templateRootPath);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -326,7 +356,7 @@ export default function EnvelopeEditor() {
|
||||
{/* Footer of left sidebar. */}
|
||||
<div className="mt-auto px-4">
|
||||
<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" />
|
||||
{isDocument ? (
|
||||
<Trans>Return to documents</Trans>
|
||||
@@ -340,13 +370,12 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{/* Main Content - Changes based on current step */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
|
||||
<AnimateGenericFadeInOut key={currentStep}>
|
||||
{match({ currentStep, isStepLoading })
|
||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
|
||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
|
||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
|
||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
|
||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
|
||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
|
||||
.exhaustive()}
|
||||
</AnimateGenericFadeInOut>
|
||||
</div>
|
||||
|
||||
@@ -20,16 +20,16 @@ export const EnvelopeItemSelector = ({
|
||||
}: EnvelopeItemSelectorProps) => {
|
||||
return (
|
||||
<button
|
||||
className={`flex min-w-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
className={`flex min-w-0 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-200 bg-blue-50 text-blue-900'
|
||||
: 'border-gray-200 bg-gray-50 hover:bg-gray-100'
|
||||
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
|
||||
: 'border-border bg-muted/50 hover:bg-muted/70'
|
||||
}`}
|
||||
{...buttonProps}
|
||||
>
|
||||
<div
|
||||
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}
|
||||
@@ -40,7 +40,7 @@ export const EnvelopeItemSelector = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn('h-2 w-2 rounded-full', {
|
||||
'bg-blue-500': isSelected,
|
||||
'bg-green-500': isSelected,
|
||||
})}
|
||||
></div>
|
||||
</button>
|
||||
|
||||
+33
-83
@@ -1,41 +1,32 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
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 Konva from 'konva';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
|
||||
export default function EnvelopeGenericPageRenderer() {
|
||||
const pageContext = usePageContext();
|
||||
const { i18n } = useLingui();
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { _className, page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const { t } = useLingui();
|
||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
});
|
||||
|
||||
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 { _className, scale } = pageContext;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
@@ -46,44 +37,6 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
[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]) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
@@ -91,6 +44,7 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
}
|
||||
|
||||
renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
@@ -103,8 +57,9 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
translations: getClientSideFieldTranslations(i18n),
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
// color: getRecipientColorKey(field.recipientId),
|
||||
color: 'purple', // Todo
|
||||
editable: false,
|
||||
@@ -113,25 +68,15 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
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);
|
||||
|
||||
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
currentPageLayer.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,14 +112,19 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||
<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
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
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 { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
|
||||
export default function EnvelopeSignerForm() {
|
||||
const { fullName, signature, setFullName, setSignature, envelope, recipientFields } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
const {
|
||||
fullName,
|
||||
signature,
|
||||
setFullName,
|
||||
setSignature,
|
||||
envelope,
|
||||
recipientFields,
|
||||
recipient,
|
||||
assistantFields,
|
||||
assistantRecipients,
|
||||
selectedAssistantRecipient,
|
||||
setSelectedAssistantRecipientId,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
const hasSignatureField = useMemo(() => {
|
||||
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
@@ -19,6 +31,63 @@ export default function EnvelopeSignerForm() {
|
||||
|
||||
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 (
|
||||
<fieldset disabled={isSubmitting} className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
|
||||
@@ -1,131 +1,139 @@
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
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 { trpc } from '@documenso/trpc/react';
|
||||
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 { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
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 { EnvelopeSignerCompleteDialog } from './envelope-signing-complete-dialog';
|
||||
|
||||
export const EnvelopeSignerHeader = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { envelope, setShowPendingFieldTooltip, recipientFieldsRemaining, recipient } =
|
||||
const { envelopeData, envelope, recipientFieldsRemaining, recipient } =
|
||||
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 (
|
||||
<nav className="w-full border-b border-gray-200 bg-white px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/">
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="whitespace-nowrap text-sm font-medium text-gray-600">
|
||||
{envelope.title}
|
||||
</h1>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Trans>Approver</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
||||
<Plural
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
value={recipientFieldsRemaining.length}
|
||||
<nav className="bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
|
||||
{/* Left side - Logo and title */}
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
||||
<Link to="/" className="flex-shrink-0">
|
||||
{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"
|
||||
/>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<BrandingLogo className="hidden h-6 w-auto md:block" />
|
||||
<BrandingLogoIcon className="h-6 w-auto md:hidden" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
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"
|
||||
/>
|
||||
<h1
|
||||
title={envelope.title}
|
||||
className="text-foreground min-w-0 truncate text-base font-semibold md:hidden"
|
||||
>
|
||||
{envelope.title}
|
||||
</h1>
|
||||
|
||||
<Separator orientation="vertical" className="hidden h-6 md:block" />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Desktop content */}
|
||||
<div className="hidden items-center space-x-2 md:flex">
|
||||
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
||||
<Plural
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
value={recipientFieldsRemaining.length}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<EnvelopeSignerCompleteDialog />
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions button */}
|
||||
<div className="flex-shrink-0 md:hidden">
|
||||
<MobileDropdownMenu />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
+146
-133
@@ -1,22 +1,25 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import type { Layer } from 'konva/lib/Layer';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
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 { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
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 { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
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 { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
|
||||
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 { handleEmailFieldClick } from '~/utils/field-signing/email-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';
|
||||
|
||||
export default function EnvelopeSignerPageRenderer() {
|
||||
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 { t } = useLingui();
|
||||
|
||||
const { i18n } = useLingui();
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
const { sessionData } = useOptionalSession();
|
||||
|
||||
const {
|
||||
envelopeData,
|
||||
recipient,
|
||||
recipientFields,
|
||||
recipientFieldsRemaining,
|
||||
showPendingFieldTooltip,
|
||||
@@ -56,71 +48,39 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
setFullName,
|
||||
signature,
|
||||
setSignature,
|
||||
selectedAssistantRecipientFields,
|
||||
selectedAssistantRecipient,
|
||||
isDirectTemplate,
|
||||
} = 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 canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
const localPageFields = useMemo(() => {
|
||||
let fieldsToRender = recipientFields;
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Layer | null>(null);
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
fieldsToRender = selectedAssistantRecipientFields;
|
||||
}
|
||||
|
||||
const viewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
return fieldsToRender.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
);
|
||||
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
recipientFields.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[recipientFields, pageContext.pageNumber],
|
||||
);
|
||||
|
||||
// Custom renderer from Konva examples.
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport,
|
||||
};
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
createPageCanvas(container);
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, viewport],
|
||||
);
|
||||
|
||||
const renderFieldOnLayer = (unparsedField: Field) => {
|
||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
@@ -137,6 +97,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
}
|
||||
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: fieldToRender.id.toString(),
|
||||
@@ -145,9 +106,11 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
height: Number(fieldToRender.height),
|
||||
positionX: Number(fieldToRender.positionX),
|
||||
positionY: Number(fieldToRender.positionY),
|
||||
signature: unparsedField.signature,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
translations: getClientSideFieldTranslations(i18n),
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
color,
|
||||
mode: 'sign',
|
||||
});
|
||||
@@ -158,19 +121,35 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
|
||||
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');
|
||||
|
||||
if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingSpinnerGroup = createSpinner({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
});
|
||||
let localEmail: string | null = email;
|
||||
let localFullName: string | null = fullName;
|
||||
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);
|
||||
|
||||
@@ -179,34 +158,39 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
* 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 || [])
|
||||
.map((v) => ({
|
||||
...v,
|
||||
checked: v.id === target.getAttr('internalCheckboxId') ? !v.checked : v.checked,
|
||||
}))
|
||||
.filter((v) => v.checked);
|
||||
|
||||
void signField(field.id, {
|
||||
type: FieldType.CHECKBOX,
|
||||
value: checkedValues.map((v) => v.id),
|
||||
}).finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
handleCheckboxFieldClick({ field, clickedCheckboxIndex })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* 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.
|
||||
const value = field.inserted && checkedValue === field.customText ? null : checkedValue;
|
||||
const value =
|
||||
field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex;
|
||||
|
||||
void signField(field.id, {
|
||||
type: FieldType.RADIO,
|
||||
@@ -222,6 +206,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
handleNumberFieldClick({ field, number: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
})
|
||||
@@ -236,6 +221,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
handleTextFieldClick({ field, text: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
})
|
||||
@@ -247,9 +233,10 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
* EMAIL FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.EMAIL }, (field) => {
|
||||
handleEmailFieldClick({ field, email })
|
||||
handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload); // Todo: Envelopes - Handle errors
|
||||
}
|
||||
|
||||
@@ -265,11 +252,12 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
* INITIALS FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.INITIALS }, (field) => {
|
||||
const initials = fullName ? extractInitials(fullName) : null;
|
||||
const initials = localFullName ? extractInitials(localFullName) : null;
|
||||
|
||||
handleInitialsFieldClick({ field, initials })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
})
|
||||
@@ -281,9 +269,10 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
* NAME FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.NAME }, (field) => {
|
||||
handleNameFieldClick({ field, name: fullName })
|
||||
handleNameFieldClick({ field, name: localFullName })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
|
||||
@@ -302,6 +291,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
handleDropdownFieldClick({ field, text: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
|
||||
@@ -315,6 +305,8 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
* DATE FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.DATE }, (field) => {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
|
||||
void signField(field.id, {
|
||||
type: FieldType.DATE,
|
||||
value: !field.inserted,
|
||||
@@ -336,6 +328,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
})
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
|
||||
@@ -348,38 +341,22 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
console.log('Field clicked');
|
||||
};
|
||||
|
||||
fieldGroup.off('click');
|
||||
fieldGroup.on('click', handleFieldGroupClick);
|
||||
fieldGroup.off('pointerdown');
|
||||
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) => {
|
||||
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,
|
||||
});
|
||||
|
||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||
// Render the fields.
|
||||
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) => {
|
||||
console.log('Field changed/inserted, rendering on canvas');
|
||||
renderFieldOnLayer(field);
|
||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||
});
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+182
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { FolderType } from '@prisma/client';
|
||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
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 { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
|
||||
|
||||
export type FolderGridProps = {
|
||||
type: FolderType;
|
||||
parentId: string | null;
|
||||
@@ -26,6 +29,7 @@ export type FolderGridProps = {
|
||||
|
||||
export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
@@ -94,8 +98,9 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
{/* Todo: Envelopes - Feature flag */}
|
||||
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
|
||||
{organisation.organisationClaim.flags.allowEnvelopes && (
|
||||
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
||||
)}
|
||||
|
||||
{type === FolderType.DOCUMENT ? (
|
||||
<DocumentUploadButton />
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
|
||||
export type DocumentsTableActionButtonProps = {
|
||||
row: TDocumentRow;
|
||||
};
|
||||
@@ -88,6 +90,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
isComplete,
|
||||
isSigned,
|
||||
isCurrentTeamDocument,
|
||||
internalVersion: row.internalVersion,
|
||||
})
|
||||
.with(
|
||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||
@@ -131,6 +134,19 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
<Trans>View</Trans>
|
||||
</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 }, () => (
|
||||
<Button className="w-32" onClick={onDownloadClick}>
|
||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
|
||||
@@ -42,6 +42,8 @@ import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialo
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
|
||||
export type DocumentsTableActionDropdownProps = {
|
||||
row: TDocumentRow;
|
||||
onMoveDocument?: () => void;
|
||||
@@ -176,15 +178,33 @@ export const DocumentsTableActionDropdown = ({
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</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}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -159,6 +159,7 @@ export const TemplatesTable = ({
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<TemplateUseDialog
|
||||
envelopeId={row.original.envelopeId}
|
||||
templateId={row.original.id}
|
||||
templateSigningOrder={row.original.templateMeta?.signingOrder}
|
||||
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
|
||||
|
||||
@@ -404,6 +404,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
|
||||
claims: {
|
||||
teamCount: organisation.organisationClaim.teamCount,
|
||||
memberCount: organisation.organisationClaim.memberCount,
|
||||
envelopeItemCount: organisation.organisationClaim.envelopeItemCount,
|
||||
flags: organisation.organisationClaim.flags,
|
||||
},
|
||||
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>
|
||||
<FormLabel>
|
||||
<Trans>Feature Flags</Trans>
|
||||
|
||||
@@ -5,7 +5,10 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
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 { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
@@ -38,12 +41,14 @@ export default function Layout() {
|
||||
recipients: 0,
|
||||
directTemplates: 0,
|
||||
},
|
||||
maximumEnvelopeItemCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
};
|
||||
}, [organisation?.subscription]);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
mapFieldsWithRecipients,
|
||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||
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 { Button } from '@documenso/ui/primitives/button';
|
||||
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 isMultiEnvelopeItem = envelope.envelopeItems.length > 1 && envelope.internalVersion === 2;
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
{envelope.status === DocumentStatus.PENDING && (
|
||||
@@ -140,40 +143,51 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
{envelope.internalVersion === 2 ? (
|
||||
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
|
||||
{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}
|
||||
>
|
||||
{isMultiEnvelopeItem && (
|
||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||
)}
|
||||
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
</EnvelopeRenderProvider>
|
||||
) : (
|
||||
<>
|
||||
{envelope.status !== DocumentStatus.COMPLETED && (
|
||||
<DocumentReadOnlyFields
|
||||
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
|
||||
documentMeta={envelope.documentMeta || undefined}
|
||||
showRecipientTooltip={true}
|
||||
showRecipientColors={true}
|
||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PDFViewer
|
||||
document={envelope}
|
||||
key={envelope.envelopeItems[0].id}
|
||||
documentData={envelope.envelopeItems[0].documentData}
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</EnvelopeRenderProvider>
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
{envelope.status !== DocumentStatus.COMPLETED && (
|
||||
<DocumentReadOnlyFields
|
||||
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
|
||||
documentMeta={envelope.documentMeta || undefined}
|
||||
showRecipientTooltip={true}
|
||||
showRecipientColors={true}
|
||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<PDFViewer
|
||||
document={envelope}
|
||||
key={envelope.envelopeItems[0].id}
|
||||
documentData={envelope.envelopeItems[0].documentData}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn('col-span-12 lg:col-span-6 xl:col-span-5', isMultiEnvelopeItem && 'mt-20')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
@@ -108,6 +109,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const isMultiEnvelopeItem = envelope.envelopeItems.length > 1 && envelope.internalVersion === 2;
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
@@ -163,39 +166,47 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
{envelope.internalVersion === 2 ? (
|
||||
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
|
||||
{envelope.internalVersion === 2 ? (
|
||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
|
||||
{isMultiEnvelopeItem && (
|
||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||
)}
|
||||
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
</EnvelopeRenderProvider>
|
||||
) : (
|
||||
<>
|
||||
<DocumentReadOnlyFields
|
||||
fields={readOnlyFields}
|
||||
showFieldStatus={false}
|
||||
showRecipientTooltip={true}
|
||||
showRecipientColors={true}
|
||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||
documentMeta={mockedDocumentMeta}
|
||||
/>
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</EnvelopeRenderProvider>
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<DocumentReadOnlyFields
|
||||
fields={readOnlyFields}
|
||||
showFieldStatus={false}
|
||||
showRecipientTooltip={true}
|
||||
showRecipientColors={true}
|
||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||
documentMeta={mockedDocumentMeta}
|
||||
/>
|
||||
|
||||
<PDFViewer
|
||||
document={envelope}
|
||||
key={envelope.envelopeItems[0].id}
|
||||
documentData={envelope.envelopeItems[0].documentData}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PDFViewer
|
||||
document={envelope}
|
||||
key={envelope.envelopeItems[0].id}
|
||||
documentData={envelope.envelopeItems[0].documentData}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<div
|
||||
className={cn('col-span-12 lg:col-span-6 xl:col-span-5', isMultiEnvelopeItem && 'mt-20')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
@@ -223,6 +234,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
|
||||
<div className="mt-4 border-t px-4 pt-4">
|
||||
<TemplateUseDialog
|
||||
envelopeId={envelope.id}
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
||||
recipients={envelope.recipients}
|
||||
|
||||
@@ -22,7 +22,9 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
|
||||
|
||||
// Hide the header for signing routes.
|
||||
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 (
|
||||
|
||||
@@ -4,20 +4,29 @@ import { redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
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 { 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 { DocumentAccessAuth } from '@documenso/lib/types/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 { 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 { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
|
||||
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 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 { token } = params;
|
||||
@@ -55,27 +64,111 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
return superLoaderJson({
|
||||
return {
|
||||
isAccessAuthValid: false as const,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return superLoaderJson({
|
||||
return {
|
||||
isAccessAuthValid: true,
|
||||
template: {
|
||||
...template,
|
||||
folder: null,
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
export default function DirectTemplatePage() {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
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.
|
||||
if (!data.isAccessAuthValid) {
|
||||
return <DirectTemplateAuthPageView />;
|
||||
@@ -97,28 +190,68 @@ export default function DirectTemplatePage() {
|
||||
recipient={directTemplateRecipient}
|
||||
user={user}
|
||||
>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</h1>
|
||||
<>
|
||||
{sessionData?.user && <AuthenticatedHeader />}
|
||||
|
||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
<p className="text-muted-foreground/80">
|
||||
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
||||
</p>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
<p className="text-muted-foreground/80">
|
||||
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DirectTemplatePageView
|
||||
directTemplateRecipient={directTemplateRecipient}
|
||||
directTemplateToken={template.directLink.token}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DirectTemplatePageView
|
||||
directTemplateRecipient={directTemplateRecipient}
|
||||
directTemplateToken={template.directLink.token}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</DocumentSigningAuthProvider>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -9,12 +9,13 @@ import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dial
|
||||
type HandleEmailFieldClickOptions = {
|
||||
field: TFieldEmail;
|
||||
email: string | null;
|
||||
placeholderEmail: string | null;
|
||||
};
|
||||
|
||||
export const handleEmailFieldClick = async (
|
||||
options: HandleEmailFieldClickOptions,
|
||||
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.EMAIL }> | null> => {
|
||||
const { field, email } = options;
|
||||
const { field, email, placeholderEmail } = options;
|
||||
|
||||
if (field.type !== FieldType.EMAIL) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
@@ -32,7 +33,9 @@ export const handleEmailFieldClick = async (
|
||||
let emailToInsert = email;
|
||||
|
||||
if (!emailToInsert) {
|
||||
emailToInsert = await SignFieldEmailDialog.call({});
|
||||
emailToInsert = await SignFieldEmailDialog.call({
|
||||
placeholderEmail,
|
||||
});
|
||||
}
|
||||
|
||||
if (!emailToInsert) {
|
||||
|
||||
@@ -30,7 +30,6 @@ export const handleSignatureFieldClick = async (
|
||||
return {
|
||||
type: FieldType.SIGNATURE,
|
||||
value: null,
|
||||
isBase64: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +50,5 @@ export const handleSignatureFieldClick = async (
|
||||
return {
|
||||
type: FieldType.SIGNATURE,
|
||||
value: signatureToInsert,
|
||||
isBase64: signatureToInsert.startsWith('data:image'),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.3.2",
|
||||
"@cantoo/pdf-lib": "^2.5.2",
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
"@documenso/auth": "*",
|
||||
@@ -104,4 +104,4 @@
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.13.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -85,6 +85,7 @@ export default defineConfig({
|
||||
'nodemailer',
|
||||
/playwright/,
|
||||
'@playwright/browser-chromium',
|
||||
'skia-canvas',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+1
-1
@@ -91,7 +91,7 @@
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.13.1",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.3.2",
|
||||
"@cantoo/pdf-lib": "^2.5.2",
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
"@documenso/auth": "*",
|
||||
|
||||
@@ -456,10 +456,22 @@ const ZCreateFieldSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
pageNumber: z.number(),
|
||||
pageX: z.number(),
|
||||
pageY: z.number(),
|
||||
pageWidth: z.number(),
|
||||
pageHeight: z.number(),
|
||||
pageX: z
|
||||
.number()
|
||||
.min(0, 'Must be between 0-100 (percentage of page width)')
|
||||
.max(100, 'Must be between 0-100 (percentage of page width)'),
|
||||
pageY: z
|
||||
.number()
|
||||
.min(0, 'Must be between 0-100 (percentage of page height)')
|
||||
.max(100, 'Must be between 0-100 (percentage of page height)'),
|
||||
pageWidth: z
|
||||
.number()
|
||||
.min(0, 'Must be between 0-100 (percentage of page width)')
|
||||
.max(100, 'Must be between 0-100 (percentage of page width)'),
|
||||
pageHeight: z
|
||||
.number()
|
||||
.min(0, 'Must be between 0-100 (percentage of page height)')
|
||||
.max(100, 'Must be between 0-100 (percentage of page height)'),
|
||||
fieldMeta: ZFieldMetaSchema.openapi({}),
|
||||
});
|
||||
|
||||
|
||||
@@ -427,18 +427,18 @@ test.describe('Document API V2', () => {
|
||||
field: {
|
||||
recipientId: recipient!.id,
|
||||
type: 'TEXT',
|
||||
pageNumber: 791.77,
|
||||
pageX: 7845.22,
|
||||
pageY: 6843.16,
|
||||
width: 3932.15,
|
||||
height: 8879.89,
|
||||
pageNumber: 1,
|
||||
pageX: 25.5,
|
||||
pageY: 30.2,
|
||||
width: 40.0,
|
||||
height: 15.5,
|
||||
fieldMeta: { type: 'text', label: 'Test Field' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect([404, 401, 500]).toContain(res.status());
|
||||
expect([400, 404, 401, 500]).toContain(res.status());
|
||||
});
|
||||
|
||||
test('should allow authorized access to document field create endpoint', async ({
|
||||
@@ -459,11 +459,11 @@ test.describe('Document API V2', () => {
|
||||
field: {
|
||||
recipientId: recipient!.id,
|
||||
type: 'TEXT',
|
||||
pageNumber: 791.77,
|
||||
pageX: 7845.22,
|
||||
pageY: 6843.16,
|
||||
width: 3932.15,
|
||||
height: 8879.89,
|
||||
pageNumber: 1,
|
||||
pageX: 25.5,
|
||||
pageY: 30.2,
|
||||
width: 40.0,
|
||||
height: 15.5,
|
||||
fieldMeta: { type: 'text', label: 'Test Field' },
|
||||
},
|
||||
},
|
||||
@@ -494,21 +494,21 @@ test.describe('Document API V2', () => {
|
||||
{
|
||||
recipientId: recipient!.id,
|
||||
type: 'TEXT',
|
||||
pageNumber: 791.77,
|
||||
pageX: 7845.22,
|
||||
pageY: 6843.16,
|
||||
width: 3932.15,
|
||||
height: 8879.89,
|
||||
pageNumber: 1,
|
||||
pageX: 25.5,
|
||||
pageY: 30.2,
|
||||
width: 40.0,
|
||||
height: 15.5,
|
||||
fieldMeta: { type: 'text', label: 'First test field' },
|
||||
},
|
||||
{
|
||||
recipientId: recipient!.id,
|
||||
type: 'TEXT',
|
||||
pageNumber: 791.77,
|
||||
pageX: 845.22,
|
||||
pageY: 843.16,
|
||||
width: 932.15,
|
||||
height: 879.89,
|
||||
pageNumber: 1,
|
||||
pageX: 50.0,
|
||||
pageY: 60.0,
|
||||
width: 35.0,
|
||||
height: 12.0,
|
||||
fieldMeta: { type: 'text', label: 'Second test field' },
|
||||
},
|
||||
],
|
||||
@@ -538,21 +538,21 @@ test.describe('Document API V2', () => {
|
||||
{
|
||||
recipientId: recipient!.id,
|
||||
type: 'TEXT',
|
||||
pageNumber: 791.77,
|
||||
pageX: 7845.22,
|
||||
pageY: 6843.16,
|
||||
width: 3932.15,
|
||||
height: 8879.89,
|
||||
pageNumber: 1,
|
||||
pageX: 25.5,
|
||||
pageY: 30.2,
|
||||
width: 40.0,
|
||||
height: 15.5,
|
||||
fieldMeta: { type: 'text', label: 'First test field' },
|
||||
},
|
||||
{
|
||||
recipientId: recipient!.id,
|
||||
type: 'TEXT',
|
||||
pageNumber: 791.77,
|
||||
pageX: 845.22,
|
||||
pageY: 843.16,
|
||||
width: 932.15,
|
||||
height: 879.89,
|
||||
pageNumber: 1,
|
||||
pageX: 50.0,
|
||||
pageY: 60.0,
|
||||
width: 35.0,
|
||||
height: 12.0,
|
||||
fieldMeta: { type: 'text', label: 'Second test field' },
|
||||
},
|
||||
],
|
||||
@@ -892,11 +892,11 @@ test.describe('Document API V2', () => {
|
||||
field: {
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
pageNumber: 5735.12,
|
||||
pageX: 936.28,
|
||||
pageY: 594.41,
|
||||
width: 589.39,
|
||||
height: 122.23,
|
||||
pageNumber: 1,
|
||||
pageX: 35.0,
|
||||
pageY: 45.0,
|
||||
width: 30.0,
|
||||
height: 10.0,
|
||||
fieldMeta: { type: 'text', label: 'Test' },
|
||||
},
|
||||
},
|
||||
@@ -931,11 +931,11 @@ test.describe('Document API V2', () => {
|
||||
field: {
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
pageNumber: 5735.12,
|
||||
pageX: 936.28,
|
||||
pageY: 594.41,
|
||||
width: 589.39,
|
||||
height: 122.23,
|
||||
pageNumber: 1,
|
||||
pageX: 35.0,
|
||||
pageY: 45.0,
|
||||
width: 30.0,
|
||||
height: 10.0,
|
||||
fieldMeta: { type: 'text', label: 'Test' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -69,11 +69,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dict
|
||||
|
||||
// Verify next recipient info is shown
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
|
||||
|
||||
// Update next recipient
|
||||
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.getByText('Next Recipient Name')).toBeVisible();
|
||||
|
||||
// Use dialog context to ensure we're targeting the correct form fields
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
@@ -458,7 +458,12 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
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.waitForURL('https://documenso.com');
|
||||
|
||||
@@ -268,17 +268,19 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
page
|
||||
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
|
||||
.evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
|
||||
|
||||
// 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
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
@@ -367,17 +369,19 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
page
|
||||
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
|
||||
.evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
|
||||
|
||||
// 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
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
@@ -83,7 +83,7 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
|
||||
await expect(page.getByText('Direct link signing has been').first()).toBeVisible();
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { ZLimitsResponseSchema } from './schema';
|
||||
|
||||
@@ -29,6 +29,7 @@ export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
||||
return {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
} satisfies TLimitsResponseSchema;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -23,3 +23,8 @@ export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||
recipients: 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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
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';
|
||||
|
||||
export type LimitsContextValue = TLimitsResponseSchema & { refreshLimits: () => Promise<void> };
|
||||
@@ -30,6 +30,7 @@ export const LimitsProvider = ({
|
||||
initialValue = {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
},
|
||||
teamId,
|
||||
children,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.
|
||||
export const ZLimitsSchema = z.object({
|
||||
documents: z
|
||||
@@ -21,6 +23,7 @@ export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
|
||||
export const ZLimitsResponseSchema = z.object({
|
||||
quota: ZLimitsSchema,
|
||||
remaining: ZLimitsSchema,
|
||||
maximumEnvelopeItemCount: z.number().optional().default(DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT),
|
||||
});
|
||||
|
||||
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
|
||||
|
||||
@@ -23,13 +23,6 @@ export const getServerLimits = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return {
|
||||
quota: SELFHOSTED_PLAN_LIMITS,
|
||||
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||
};
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
teams: {
|
||||
@@ -57,12 +50,22 @@ export const getServerLimits = async ({
|
||||
const remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||
|
||||
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.
|
||||
if (organisation.organisationClaimId === INTERNAL_CLAIM_ID.ENTERPRISE) {
|
||||
return {
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +74,7 @@ export const getServerLimits = async ({
|
||||
return {
|
||||
quota: INACTIVE_PLAN_LIMITS,
|
||||
remaining: INACTIVE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +84,7 @@ export const getServerLimits = async ({
|
||||
return {
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,5 +122,6 @@ export const getServerLimits = async ({
|
||||
return {
|
||||
quota,
|
||||
remaining,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
import type { TCheckboxFieldMeta } from '../types/field-meta';
|
||||
@@ -75,3 +77,15 @@ export const validateCheckboxField = (
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const validateCheckboxLength = (
|
||||
numberOfSelectedOptions: number,
|
||||
validationRule: '>=' | '=' | '<=',
|
||||
validationLength: number,
|
||||
) => {
|
||||
return match(validationRule)
|
||||
.with('>=', () => numberOfSelectedOptions >= validationLength)
|
||||
.with('=', () => numberOfSelectedOptions === validationLength)
|
||||
.with('<=', () => numberOfSelectedOptions <= validationLength)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export const validateNumberField = (
|
||||
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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,6 @@ export const useEditorFields = ({
|
||||
}
|
||||
|
||||
if (bypassCheck) {
|
||||
console.log(3);
|
||||
setSelectedFieldFormId(formId);
|
||||
return;
|
||||
}
|
||||
@@ -136,6 +135,7 @@ export const useEditorFields = ({
|
||||
const field: TLocalField = {
|
||||
...fieldData,
|
||||
formId: nanoid(12),
|
||||
...restrictFieldPosValues(fieldData),
|
||||
};
|
||||
|
||||
append(field);
|
||||
@@ -165,7 +165,15 @@ export const useEditorFields = ({
|
||||
const index = localFields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
update(index, { ...localFields[index], ...updates });
|
||||
const updatedField = {
|
||||
...localFields[index],
|
||||
...updates,
|
||||
};
|
||||
|
||||
update(index, {
|
||||
...updatedField,
|
||||
...restrictFieldPosValues(updatedField),
|
||||
});
|
||||
triggerFieldsUpdate();
|
||||
}
|
||||
},
|
||||
@@ -279,3 +287,14 @@ export const useEditorFields = ({
|
||||
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)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -5,15 +5,14 @@ import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type { RecipientColorStyles, TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import {
|
||||
AVAILABLE_RECIPIENT_COLORS,
|
||||
getRecipientColorStyles,
|
||||
} from '@documenso/ui/lib/recipient-colors';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
|
||||
import { useEditorFields } from '../hooks/use-editor-fields';
|
||||
import type { TLocalField } from '../hooks/use-editor-fields';
|
||||
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 = {
|
||||
envelope: TEnvelope;
|
||||
isDocument: boolean;
|
||||
isTemplate: boolean;
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
||||
|
||||
updateEnvelope: (envelopeUpdates: Partial<TEnvelope>) => void;
|
||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
|
||||
|
||||
getFieldColor: (field: TLocalField) => RecipientColorStyles;
|
||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||
|
||||
editorFields: ReturnType<typeof useEditorFields>;
|
||||
|
||||
isAutosaving: boolean;
|
||||
flushAutosave: () => void;
|
||||
flushAutosave: () => Promise<void>;
|
||||
autosaveError: boolean;
|
||||
|
||||
relativePath: {
|
||||
basePath: string;
|
||||
envelopePath: string;
|
||||
editorPath: string;
|
||||
documentRootPath: string;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
syncEnvelope: () => Promise<void>;
|
||||
// refetchEnvelope: () => Promise<void>;
|
||||
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
|
||||
};
|
||||
@@ -86,12 +95,10 @@ export const EnvelopeEditorProvider = ({
|
||||
const { toast } = useToast();
|
||||
|
||||
const [envelope, setEnvelope] = useState(initialEnvelope);
|
||||
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
|
||||
onSuccess: (response, input) => {
|
||||
console.log(input.meta?.emailSettings);
|
||||
setEnvelope({
|
||||
...envelope,
|
||||
...response,
|
||||
@@ -106,7 +113,9 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@@ -122,7 +131,9 @@ export const EnvelopeEditorProvider = ({
|
||||
onSuccess: () => {
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@@ -135,10 +146,17 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: ({ recipients }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
recipients,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@@ -178,21 +196,28 @@ export const EnvelopeEditorProvider = ({
|
||||
triggerSave: setEnvelopeDebounced,
|
||||
flush: setEnvelopeAsync,
|
||||
isPending: isEnvelopeMutationPending,
|
||||
} = useEnvelopeAutosave(async (envelopeUpdates: Partial<TEnvelope>) => {
|
||||
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
data: {
|
||||
...envelopeUpdates,
|
||||
},
|
||||
data: envelopeUpdates.data,
|
||||
meta: envelopeUpdates.meta,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
/**
|
||||
* Updates the local envelope and debounces the update to the server.
|
||||
*/
|
||||
const updateEnvelope = (envelopeUpdates: Partial<TEnvelope>) => {
|
||||
setEnvelope((prev) => ({ ...prev, ...envelopeUpdates }));
|
||||
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
...envelopeUpdates.data,
|
||||
meta: {
|
||||
...prev.documentMeta,
|
||||
...envelopeUpdates.meta,
|
||||
},
|
||||
}));
|
||||
|
||||
setEnvelopeDebounced(envelopeUpdates);
|
||||
};
|
||||
|
||||
@@ -201,28 +226,17 @@ export const EnvelopeEditorProvider = ({
|
||||
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(
|
||||
(recipientId: number) => {
|
||||
// Todo: Envelopes - Local recipients
|
||||
const recipientIndex = envelope.recipients.findIndex(
|
||||
(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(
|
||||
@@ -234,6 +248,21 @@ export const EnvelopeEditorProvider = ({
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch and sycn the envelope back into the editor.
|
||||
*
|
||||
* Overrides everything.
|
||||
*/
|
||||
const syncEnvelope = async () => {
|
||||
await flushAutosave();
|
||||
|
||||
const fetchedEnvelopeData = await reloadEnvelope();
|
||||
|
||||
if (fetchedEnvelopeData.data) {
|
||||
setEnvelope(fetchedEnvelopeData.data);
|
||||
}
|
||||
};
|
||||
|
||||
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
|
||||
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
|
||||
};
|
||||
@@ -256,10 +285,23 @@ export const EnvelopeEditorProvider = ({
|
||||
isEnvelopeMutationPending,
|
||||
]);
|
||||
|
||||
const flushAutosave = () => {
|
||||
void setFieldsAsync();
|
||||
void setRecipientsAsync();
|
||||
void setEnvelopeAsync();
|
||||
const relativePath = useMemo(() => {
|
||||
const documentRootPath = formatDocumentsPath(envelope.team.url);
|
||||
const templateRootPath = formatTemplatesPath(envelope.team.url);
|
||||
|
||||
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 (
|
||||
@@ -269,7 +311,6 @@ export const EnvelopeEditorProvider = ({
|
||||
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
||||
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
||||
setLocalEnvelope,
|
||||
getFieldColor,
|
||||
getRecipientColorKey,
|
||||
updateEnvelope,
|
||||
setRecipientsDebounced,
|
||||
@@ -278,6 +319,8 @@ export const EnvelopeEditorProvider = ({
|
||||
autosaveError,
|
||||
flushAutosave,
|
||||
isAutosaving,
|
||||
relativePath,
|
||||
syncEnvelope,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
||||
|
||||
export const DEFAULT_STANDARD_FONT_SIZE = 12;
|
||||
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_HANDWRITING_FONT_SIZE = 20;
|
||||
|
||||
@@ -18,6 +18,8 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
|
||||
embedSigning: z.literal(true).optional(),
|
||||
embedSigningWhiteLabel: z.literal(true).optional(),
|
||||
cfr21: z.literal(true).optional(),
|
||||
// Todo: Envelopes - Do we need to check?
|
||||
// authenticationPortal & emailDomains missing here.
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
DocumentStatus,
|
||||
@@ -9,6 +17,8 @@ import {
|
||||
} from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import { groupBy } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
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 { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
||||
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 { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
|
||||
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
|
||||
@@ -178,9 +189,10 @@ export const run = async ({
|
||||
settings,
|
||||
});
|
||||
|
||||
// Todo: Envelopes - Is it okay to have dynamic IDs?
|
||||
const newDocumentData = await Promise.all(
|
||||
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(
|
||||
(item) => item.id === envelopeItem.id,
|
||||
)?.field;
|
||||
@@ -353,14 +365,95 @@ const decorateAndSignPdf = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of envelopeItemFields) {
|
||||
if (field.inserted) {
|
||||
if (envelope.internalVersion === 2) {
|
||||
await insertFieldInPDFV2(pdfDoc, field);
|
||||
} else if (envelope.useLegacyFieldInsertion) {
|
||||
await legacy_insertFieldInPDF(pdfDoc, field);
|
||||
} else {
|
||||
await insertFieldInPDFV1(pdfDoc, field);
|
||||
// Handle V1 and legacy insertions.
|
||||
if (envelope.internalVersion === 1) {
|
||||
for (const field of envelopeItemFields) {
|
||||
if (field.inserted) {
|
||||
if (envelope.useLegacyFieldInsertion) {
|
||||
await legacy_insertFieldInPDF(pdfDoc, field);
|
||||
} else {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const getDocumentWithDetailsById = async ({
|
||||
return {
|
||||
...envelope,
|
||||
envelopeId: envelope.id,
|
||||
internalVersion: envelope.internalVersion,
|
||||
documentData: {
|
||||
...firstDocumentData,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
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 { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
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 { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@@ -24,6 +29,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@@ -56,6 +62,7 @@ export const sendDocument = async ({
|
||||
recipients: {
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
},
|
||||
fields: true,
|
||||
documentMeta: true,
|
||||
envelopeItems: {
|
||||
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) => {
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
@@ -177,6 +256,23 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Todo: Envelopes - [AUDIT_LOGS]
|
||||
if (envelope.internalVersion === 2) {
|
||||
await Promise.all(
|
||||
fieldsToAutoInsert.map(async (field) => {
|
||||
await tx.field.update({
|
||||
where: {
|
||||
id: field.fieldId,
|
||||
},
|
||||
data: {
|
||||
customText: field.customText,
|
||||
inserted: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export type GetRecipientEnvelopeByTokenOptions = {
|
||||
accessAuth?: TDocumentAuthMethods;
|
||||
};
|
||||
|
||||
const ZEnvelopeForSigningResponse = z.object({
|
||||
export const ZEnvelopeForSigningResponse = z.object({
|
||||
envelope: EnvelopeSchema.pick({
|
||||
type: true,
|
||||
status: true,
|
||||
@@ -31,6 +31,7 @@ const ZEnvelopeForSigningResponse = z.object({
|
||||
secondaryId: true,
|
||||
internalVersion: true,
|
||||
completedAt: true,
|
||||
updatedAt: true,
|
||||
deletedAt: true,
|
||||
title: true,
|
||||
authOptions: true,
|
||||
|
||||
@@ -54,3 +54,54 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
|
||||
recipientHasAccount: Boolean(recipientUserAccount),
|
||||
} 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;
|
||||
};
|
||||
|
||||
@@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
|
||||
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const errors = validateNumberField(
|
||||
String(numberFieldParsedMeta.value),
|
||||
numberFieldParsedMeta,
|
||||
false,
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -205,6 +205,7 @@ export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalCla
|
||||
flags: {
|
||||
...subscriptionClaim.flags,
|
||||
},
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
};
|
||||
|
||||
@@ -1,133 +1,64 @@
|
||||
import type { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { renderField } from '../../universal/field-renderer/render-field';
|
||||
import { getPageSize } from './get-page-size';
|
||||
|
||||
// const font = await pdf.embedFont(
|
||||
// isSignatureField ? fontCaveat : fontNoto,
|
||||
// isSignatureField ? { features: { calt: false } } : undefined,
|
||||
// );
|
||||
// const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
|
||||
// const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
type InsertFieldInPDFV2Options = {
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
fields: FieldWithSignature[];
|
||||
};
|
||||
|
||||
export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
export const insertFieldInPDFV2 = async ({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fields,
|
||||
}: InsertFieldInPDFV2Options) => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
FontLibrary.use([
|
||||
path.join(fontPath, 'caveat.ttf'),
|
||||
path.join(fontPath, 'noto-sans.ttf'),
|
||||
path.join(fontPath, 'noto-sans-japanese.ttf'),
|
||||
path.join(fontPath, 'noto-sans-chinese.ttf'),
|
||||
path.join(fontPath, 'noto-sans-korean.ttf'),
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
const pages = pdf.getPages();
|
||||
|
||||
const page = pages.at(field.page - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Page ${field.page} does not exist`);
|
||||
}
|
||||
|
||||
const pageRotation = page.getRotation();
|
||||
|
||||
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;
|
||||
|
||||
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
|
||||
|
||||
// Todo: Evenloeps - getPageSize this had extra logic? Ask lucas
|
||||
|
||||
console.log({
|
||||
cropBox: page.getCropBox(),
|
||||
mediaBox: page.getMediaBox(),
|
||||
mediaBox2: page.getSize(),
|
||||
});
|
||||
|
||||
const { width: pageWidth, height: pageHeight } = getPageSize(page);
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Then when we insert the fields, we apply a transformation to the position of the field
|
||||
// so it is rotated correctly.
|
||||
if (isPageRotatedToLandscape) {
|
||||
// [pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
|
||||
console.log({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fieldWidth: field.width,
|
||||
fieldHeight: field.height,
|
||||
});
|
||||
|
||||
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
|
||||
const layer = new Konva.Layer();
|
||||
|
||||
// Will render onto the layer.
|
||||
renderField({
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
},
|
||||
pageLayer: layer,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
mode: 'export',
|
||||
});
|
||||
const insertedFields = fields.filter((field) => field.inserted);
|
||||
|
||||
// Render the fields onto the layer.
|
||||
for (const field of insertedFields) {
|
||||
renderField({
|
||||
scale: 1,
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
},
|
||||
translations: null,
|
||||
pageLayer: layer,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
mode: 'export',
|
||||
});
|
||||
}
|
||||
|
||||
stage.add(layer);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const canvas = layer.canvas._canvas as unknown as Canvas;
|
||||
|
||||
const renderedField = await canvas.toBuffer('svg');
|
||||
|
||||
fs.writeFileSync(
|
||||
`rendered-field-${field.envelopeId}--${field.id}.svg`,
|
||||
renderedField.toString('utf-8'),
|
||||
);
|
||||
|
||||
// Embed the SVG into the PDF
|
||||
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
|
||||
|
||||
// Calculate position to cover the whole page
|
||||
// pdf-lib coordinates: (0,0) is bottom-left, y increases upward
|
||||
const svgWidth = pageWidth; // Use full page width
|
||||
const svgHeight = pageHeight; // Use full page height
|
||||
|
||||
const x = 0; // Start from left edge
|
||||
const y = pageHeight; // Start from bottom edge
|
||||
|
||||
// Draw the SVG on the page
|
||||
page.drawSvg(svgElement, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: svgWidth,
|
||||
height: svgHeight,
|
||||
});
|
||||
|
||||
return pdf;
|
||||
return await canvas.toBuffer('pdf');
|
||||
};
|
||||
|
||||
@@ -162,12 +162,6 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
||||
}
|
||||
|
||||
if (user && user.email !== directRecipientEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Email must match if you are logged in',
|
||||
});
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
|
||||
extractDocumentAuthMethods({
|
||||
documentAuth: directTemplateEnvelope.authOptions,
|
||||
@@ -340,7 +334,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: incrementedDocumentId.formattedDocumentId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
internalVersion: 1,
|
||||
internalVersion: directTemplateEnvelope.internalVersion,
|
||||
qrToken: prefixedId('qr'),
|
||||
source: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
templateId: directTemplateEnvelopeLegacyId,
|
||||
|
||||
@@ -209,6 +209,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
|
||||
type: 'radio',
|
||||
label: field.label,
|
||||
values: newValues,
|
||||
direction: radioMeta.direction ?? 'vertical',
|
||||
};
|
||||
|
||||
return meta;
|
||||
@@ -395,8 +396,6 @@ export const createDocumentFromTemplate = async ({
|
||||
};
|
||||
});
|
||||
|
||||
const firstEnvelopeItemId = template.envelopeItems[0].id;
|
||||
|
||||
// Key = original envelope item ID
|
||||
// Value = duplicated envelope item ID.
|
||||
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
||||
@@ -407,10 +406,14 @@ export const createDocumentFromTemplate = async ({
|
||||
template.envelopeItems.map(async (item, i) => {
|
||||
let documentDataIdToDuplicate = item.documentDataId;
|
||||
|
||||
const foundCustomDocumentData = customDocumentData.find(
|
||||
(customDocumentDataItem) =>
|
||||
customDocumentDataItem.envelopeItemId || firstEnvelopeItemId === item.id,
|
||||
);
|
||||
const foundCustomDocumentData = customDocumentData.find((customDocumentDataItem) => {
|
||||
// Handle empty envelopeItemId for backwards compatibility reasons.
|
||||
if (customDocumentDataItem.documentDataId && !customDocumentDataItem.envelopeItemId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return customDocumentDataItem.envelopeItemId === item.id;
|
||||
});
|
||||
|
||||
if (foundCustomDocumentData) {
|
||||
documentDataIdToDuplicate = foundCustomDocumentData.documentDataId;
|
||||
|
||||
@@ -38,7 +38,6 @@ export const getTemplateByDirectLinkToken = async ({
|
||||
|
||||
const directLink = envelope?.directLink;
|
||||
|
||||
// Todo: Envelopes
|
||||
const firstDocumentData = envelope?.envelopeItems[0]?.documentData;
|
||||
|
||||
// Doing this to enforce type safety for directLink.
|
||||
|
||||
+314
-101
File diff suppressed because it is too large
Load Diff
+314
-101
File diff suppressed because it is too large
Load Diff
+314
-101
File diff suppressed because it is too large
Load Diff
+314
-101
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user