Compare commits
2 Commits
fix/field-
...
feat/bulk-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
347e7a0b40 | ||
|
|
10f6163a00 |
@@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
|
|||||||
<EmbedDirectTemplate
|
<EmbedDirectTemplate
|
||||||
token={token}
|
token={token}
|
||||||
cssVars={{
|
cssVars={{
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
`}
|
`}
|
||||||
// CSS Variables
|
// CSS Variables
|
||||||
cssVars={{
|
cssVars={{
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
// Dark Mode Control
|
// Dark Mode Control
|
||||||
darkModeDisabled={true}
|
darkModeDisabled={true}
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -14,208 +9,64 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
export type NextSigner = {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConfirmationDialogProps = {
|
type ConfirmationDialogProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (nextSigner?: NextSigner) => void;
|
onConfirm: () => void;
|
||||||
hasUninsertedFields: boolean;
|
hasUninsertedFields: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
allowDictateNextSigner?: boolean;
|
|
||||||
defaultNextSigner?: NextSigner;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZNextSignerFormSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
email: z.string().email('Invalid email address'),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
|
||||||
|
|
||||||
export function AssistantConfirmationDialog({
|
export function AssistantConfirmationDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
hasUninsertedFields,
|
hasUninsertedFields,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
allowDictateNextSigner = false,
|
|
||||||
defaultNextSigner,
|
|
||||||
}: ConfirmationDialogProps) {
|
}: ConfirmationDialogProps) {
|
||||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
|
||||||
resolver: zodResolver(ZNextSignerFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onOpenChange = () => {
|
const onOpenChange = () => {
|
||||||
if (form.formState.isSubmitting) {
|
if (isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsEditingNextSigner(false);
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
|
||||||
if (allowDictateNextSigner && data.name && data.email) {
|
|
||||||
await onConfirm({
|
|
||||||
name: data.name,
|
|
||||||
email: data.email,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onConfirm();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
disabled={form.formState.isSubmitting || isSubmitting}
|
|
||||||
className="border-none p-0"
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Complete Document</Trans>
|
<Trans>Complete Document</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
Are you sure you want to complete the document? This action cannot be undone.
|
Are you sure you want to complete the document? This action cannot be undone. Please
|
||||||
Please ensure that you have completed prefilling all relevant fields before
|
ensure that you have completed prefilling all relevant fields before proceeding.
|
||||||
proceeding.
|
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{allowDictateNextSigner && (
|
<DocumentSigningDisclosure />
|
||||||
<div className="space-y-4">
|
|
||||||
{!isEditingNextSigner && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
The next recipient to sign this document will be{' '}
|
|
||||||
<span className="font-semibold">{form.watch('name')}</span> (
|
|
||||||
<span className="font-semibold">{form.watch('email')}</span>).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="mt-2"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<Trans>Update Recipient</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditingNextSigner && (
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's name"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="email"
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's email"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<Button
|
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
type="button"
|
Cancel
|
||||||
variant="secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
|
||||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
||||||
disabled={form.formState.isSubmitting || !isNextSignerValid}
|
onClick={onConfirm}
|
||||||
loading={form.formState.isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting ? (
|
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
|
||||||
<Trans>Submitting...</Trans>
|
|
||||||
) : hasUninsertedFields ? (
|
|
||||||
<Trans>Proceed</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { P, match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
@@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
<Trans>By deleting this document, the following will occur:</Trans>
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline">
|
<Button>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
<Trans>Bulk Send via CSV</Trans>
|
<Trans>Bulk Send via CSV</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||||
|
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@@ -13,10 +13,6 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
|
|||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import {
|
|
||||||
isFieldUnsignedAndRequired,
|
|
||||||
isRequiredField,
|
|
||||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type {
|
import type {
|
||||||
@@ -25,11 +21,12 @@ import type {
|
|||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
@@ -68,8 +65,16 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const { fullName, email, signature, setFullName, setEmail, setSignature } =
|
const {
|
||||||
useRequiredDocumentSigningContext();
|
fullName,
|
||||||
|
email,
|
||||||
|
signature,
|
||||||
|
signatureValid,
|
||||||
|
setFullName,
|
||||||
|
setEmail,
|
||||||
|
setSignature,
|
||||||
|
setSignatureValid,
|
||||||
|
} = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
@@ -87,7 +92,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
localFields.filter((field) => !field.inserted),
|
||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -105,7 +110,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const newField: DirectTemplateLocalField = structuredClone({
|
const newField: DirectTemplateLocalField = structuredClone({
|
||||||
...field,
|
...field,
|
||||||
customText: payload.value ?? '',
|
customText: payload.value,
|
||||||
inserted: true,
|
inserted: true,
|
||||||
signedValue: payload,
|
signedValue: payload,
|
||||||
});
|
});
|
||||||
@@ -116,10 +121,8 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64:
|
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
||||||
payload.value && payload.value.startsWith('data:') ? payload.value : null,
|
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
||||||
typedSignature:
|
|
||||||
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
|
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +180,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(pendingFields);
|
validateFieldsInserted(localFields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -185,7 +188,11 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const onCompleteClick = async () => {
|
const onCompleteClick = async () => {
|
||||||
try {
|
try {
|
||||||
const valid = validateFieldsInserted(pendingFields);
|
if (hasSignatureField && !signatureValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = validateFieldsInserted(localFields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -198,6 +205,12 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localFields.forEach((field) => {
|
||||||
|
if (!field.signedValue) {
|
||||||
|
throw new Error('Invalid configuration');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
documentId,
|
documentId,
|
||||||
token: documentToken,
|
token: documentToken,
|
||||||
@@ -208,11 +221,13 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directRecipientName: fullName,
|
directRecipientName: fullName,
|
||||||
directRecipientEmail: email,
|
directRecipientEmail: email,
|
||||||
templateUpdatedAt: updatedAt,
|
templateUpdatedAt: updatedAt,
|
||||||
signedFieldValues: localFields
|
signedFieldValues: localFields.map((field) => {
|
||||||
.filter((field) => {
|
if (!field.signedValue) {
|
||||||
return field.signedValue && (isRequiredField(field) || field.inserted);
|
throw new Error('Invalid configuration');
|
||||||
})
|
}
|
||||||
.map((field) => field.signedValue!),
|
|
||||||
|
return field.signedValue;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
@@ -400,26 +415,42 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && (
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Signature">
|
<Label htmlFor="Signature">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<SignaturePadDialog
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
className="mt-2"
|
<CardContent className="p-0">
|
||||||
|
<SignaturePad
|
||||||
|
className="h-44 w-full"
|
||||||
disabled={isThrottled || isSubmitting}
|
disabled={isThrottled || isSubmitting}
|
||||||
disableAnimation
|
defaultValue={signature ?? undefined}
|
||||||
value={signature ?? ''}
|
onChange={(value) => {
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
setSignature(value);
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
}}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
onValidityChange={(isValid) => {
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
setSignatureValid(isValid);
|
||||||
|
}}
|
||||||
|
allowTypedSignature={Boolean(
|
||||||
|
metadata &&
|
||||||
|
'typedSignatureEnabled' in metadata &&
|
||||||
|
metadata.typedSignatureEnabled,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{hasSignatureField && !signatureValid && (
|
||||||
|
<div className="text-destructive mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
|
Signature is too small. Please provide a more complete signature.
|
||||||
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type EmbedDocumentCompletedPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||||
|
console.log({ signature });
|
||||||
return (
|
return (
|
||||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export const EmbedDocumentFields = ({
|
|||||||
onSignField={onSignField}
|
onSignField={onSignField}
|
||||||
onUnsignField={onUnsignField}
|
onUnsignField={onUnsignField}
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
import { useEffect, useId, useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
@@ -15,18 +15,18 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
@@ -69,8 +69,15 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { fullName, email, signature, setFullName, setSignature } =
|
const {
|
||||||
useRequiredDocumentSigningContext();
|
fullName,
|
||||||
|
email,
|
||||||
|
signature,
|
||||||
|
signatureValid,
|
||||||
|
setFullName,
|
||||||
|
setSignature,
|
||||||
|
setSignatureValid,
|
||||||
|
} = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
@@ -94,26 +101,19 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
fields.filter(
|
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
|
||||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
|
||||||
),
|
|
||||||
fields.filter((field) => field.inserted),
|
fields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
|
||||||
[fields],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -121,7 +121,11 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
|
|
||||||
const onCompleteClick = async () => {
|
const onCompleteClick = async () => {
|
||||||
try {
|
try {
|
||||||
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
if (hasSignatureField && !signatureValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = validateFieldsInserted(fields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -414,24 +418,40 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && (
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Signature">
|
<Label htmlFor="Signature">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<SignaturePadDialog
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
className="mt-2"
|
<CardContent className="p-0">
|
||||||
|
<SignaturePad
|
||||||
|
className="h-44 w-full"
|
||||||
disabled={isThrottled || isSubmitting}
|
disabled={isThrottled || isSubmitting}
|
||||||
disableAnimation
|
defaultValue={signature ?? undefined}
|
||||||
value={signature ?? ''}
|
onChange={(value) => {
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
setSignature(value);
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
}}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
onValidityChange={(isValid) => {
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
setSignatureValid(isValid);
|
||||||
|
}}
|
||||||
|
allowTypedSignature={Boolean(
|
||||||
|
metadata &&
|
||||||
|
'typedSignatureEnabled' in metadata &&
|
||||||
|
metadata.typedSignatureEnabled,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{hasSignatureField && !signatureValid && (
|
||||||
|
<div className="text-destructive mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
|
Signature is too small. Please provide a more complete signature.
|
||||||
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -447,7 +467,9 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
||||||
disabled={isThrottled}
|
disabled={
|
||||||
|
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||||
|
}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={() => throttledOnCompleteClick()}
|
onClick={() => throttledOnCompleteClick()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,15 +19,12 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZProfileFormSchema = z.object({
|
export const ZProfileFormSchema = z.object({
|
||||||
name: z
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
.string()
|
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||||
.trim()
|
|
||||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
|
||||||
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZTwoFactorAuthTokenSchema = z.object({
|
export const ZTwoFactorAuthTokenSchema = z.object({
|
||||||
@@ -112,20 +109,22 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="signature"
|
name="signature"
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePadDialog
|
<SignaturePad
|
||||||
|
className="h-44 w-full"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={value}
|
containerClassName={cn('rounded-lg border bg-background')}
|
||||||
|
defaultValue={user.signature ?? undefined}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
|
allowTypedSignature={true}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -135,7 +134,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<Button type="submit" loading={isSubmitting} className="self-end">
|
<Button type="submit" loading={isSubmitting} className="self-end">
|
||||||
<Trans>Update profile</Trans>
|
{isSubmitting ? <Trans>Updating profile...</Trans> : <Trans>Update profile</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
|
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
|
||||||
@@ -353,15 +353,16 @@ export const SignUpForm = ({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="signature"
|
name="signature"
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Sign Here</Trans>
|
<Trans>Sign Here</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePadDialog
|
<SignaturePad
|
||||||
|
className="h-36 w-full"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={value}
|
containerClassName="mt-2 rounded-lg border bg-background"
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -530,27 +531,6 @@ export const SignUpForm = ({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<p className="text-muted-foreground mt-6 text-xs">
|
|
||||||
<Trans>
|
|
||||||
By proceeding, you agree to our{' '}
|
|
||||||
<Link
|
|
||||||
to="https://documen.so/terms"
|
|
||||||
target="_blank"
|
|
||||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
|
||||||
>
|
|
||||||
Terms of Service
|
|
||||||
</Link>{' '}
|
|
||||||
and{' '}
|
|
||||||
<Link
|
|
||||||
to="https://documen.so/privacy"
|
|
||||||
target="_blank"
|
|
||||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
|
|||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Update</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -8,15 +8,12 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
|
||||||
import {
|
import {
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
SUPPORTED_LANGUAGE_CODES,
|
SUPPORTED_LANGUAGE_CODES,
|
||||||
isValidLanguageCode,
|
isValidLanguageCode,
|
||||||
} from '@documenso/lib/constants/i18n';
|
} from '@documenso/lib/constants/i18n';
|
||||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -26,9 +23,7 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -43,10 +38,8 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
|
|||||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
includeSenderDetails: z.boolean(),
|
includeSenderDetails: z.boolean(),
|
||||||
|
typedSignatureEnabled: z.boolean(),
|
||||||
includeSigningCertificate: z.boolean(),
|
includeSigningCertificate: z.boolean(),
|
||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
||||||
@@ -76,8 +69,8 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
? settings?.documentLanguage
|
? settings?.documentLanguage
|
||||||
: 'en',
|
: 'en',
|
||||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||||
|
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
|
||||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
||||||
signatureTypes: extractTeamSignatureSettings(settings),
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
@@ -91,7 +84,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
signatureTypes,
|
typedSignatureEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
await updateTeamDocumentPreferences({
|
await updateTeamDocumentPreferences({
|
||||||
@@ -100,10 +93,8 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentVisibility,
|
documentVisibility,
|
||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
|
typedSignatureEnabled,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,44 +190,6 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="signatureTypes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel className="flex flex-row items-center">
|
|
||||||
<Trans>Default Signature Settings</Trans>
|
|
||||||
<DocumentSignatureSettingsTooltip />
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
|
||||||
label: _(option.label),
|
|
||||||
value: option.value,
|
|
||||||
}))}
|
|
||||||
selectedValues={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
className="bg-background w-full"
|
|
||||||
enableSearch={false}
|
|
||||||
emptySelectionPlaceholder="Select signature types"
|
|
||||||
testId="signature-types-combobox"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{form.formState.errors.signatureTypes ? (
|
|
||||||
<FormMessage />
|
|
||||||
) : (
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls which signatures are allowed to be used when signing a document.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeSenderDetails"
|
name="includeSenderDetails"
|
||||||
@@ -285,6 +238,36 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="typedSignatureEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Enable Typed Signature</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormControl className="block">
|
||||||
|
<Switch
|
||||||
|
ref={field.ref}
|
||||||
|
name={field.name}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls whether the recipients can sign the documents using a typed signature.
|
||||||
|
Enable or disable the typed signature globally.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="includeSigningCertificate"
|
name="includeSigningCertificate"
|
||||||
@@ -318,7 +301,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Update</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const AppNavDesktop = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg"
|
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
||||||
onClick={() => setIsCommandMenuOpen(true)}
|
onClick={() => setIsCommandMenuOpen(true)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TTemplate } from '@documenso/lib/types/template';
|
import type { TTemplate } from '@documenso/lib/types/template';
|
||||||
import {
|
|
||||||
DocumentReadOnlyFields,
|
|
||||||
mapFieldsWithRecipients,
|
|
||||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
@@ -20,6 +16,7 @@ import {
|
|||||||
DocumentFlowFormContainerHeader,
|
DocumentFlowFormContainerHeader,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -100,16 +97,14 @@ export const DirectTemplateConfigureForm = ({
|
|||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
|
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
{isDocumentPdfLoaded && (
|
{isDocumentPdfLoaded &&
|
||||||
<DocumentReadOnlyFields
|
directTemplateRecipient.fields.map((field, index) => (
|
||||||
fields={mapFieldsWithRecipients(
|
<ShowFieldItem
|
||||||
directTemplateRecipient.fields,
|
key={index}
|
||||||
recipientsWithBlankDirectRecipientEmail,
|
field={field}
|
||||||
)}
|
recipients={recipientsWithBlankDirectRecipientEmail}
|
||||||
recipientIds={recipients.map((recipient) => recipient.id)}
|
|
||||||
showRecipientColors={true}
|
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<fieldset
|
<fieldset
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field, Recipient, Signature } from '@prisma/client';
|
import type { Field, Recipient, Signature } from '@prisma/client';
|
||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
@@ -34,7 +35,7 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useStep } from '@documenso/ui/primitives/stepper';
|
import { useStep } from '@documenso/ui/primitives/stepper';
|
||||||
|
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||||
@@ -72,7 +73,8 @@ export const DirectTemplateSigningForm = ({
|
|||||||
template,
|
template,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: DirectTemplateSigningFormProps) => {
|
}: DirectTemplateSigningFormProps) => {
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
const { fullName, signature, signatureValid, setFullName, setSignature } =
|
||||||
|
useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
|
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
@@ -89,7 +91,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
|
|
||||||
const tempField: DirectTemplateLocalField = {
|
const tempField: DirectTemplateLocalField = {
|
||||||
...field,
|
...field,
|
||||||
customText: value.value ?? '',
|
customText: value.value,
|
||||||
inserted: true,
|
inserted: true,
|
||||||
signedValue: value,
|
signedValue: value,
|
||||||
};
|
};
|
||||||
@@ -100,8 +102,8 @@ export const DirectTemplateSigningForm = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null,
|
signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null,
|
||||||
typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null,
|
typedSignature: value.value.startsWith('data:') ? null : value.value,
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +135,8 @@ export const DirectTemplateSigningForm = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
const uninsertedFields = useMemo(() => {
|
||||||
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
|
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
|
||||||
}, [localFields]);
|
}, [localFields]);
|
||||||
@@ -145,6 +149,10 @@ export const DirectTemplateSigningForm = ({
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
|
if (hasSignatureField && !signatureValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(localFields);
|
const isFieldsValid = validateFieldsInserted(localFields);
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
if (!isFieldsValid) {
|
||||||
@@ -162,55 +170,6 @@ export const DirectTemplateSigningForm = ({
|
|||||||
// Do not reset to false since we do a redirect.
|
// Do not reset to false since we do a redirect.
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updatedFields = [...localFields];
|
|
||||||
|
|
||||||
localFields.forEach((field) => {
|
|
||||||
const index = updatedFields.findIndex((f) => f.id === field.id);
|
|
||||||
let value = '';
|
|
||||||
|
|
||||||
match(field.type)
|
|
||||||
.with(FieldType.TEXT, () => {
|
|
||||||
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
|
|
||||||
|
|
||||||
if (meta?.success) {
|
|
||||||
value = meta.data.text ?? '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.with(FieldType.NUMBER, () => {
|
|
||||||
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
|
|
||||||
|
|
||||||
if (meta?.success) {
|
|
||||||
value = meta.data.value ?? '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.with(FieldType.DROPDOWN, () => {
|
|
||||||
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
|
|
||||||
|
|
||||||
if (meta?.success) {
|
|
||||||
value = meta.data.defaultValue ?? '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
const signedValue = {
|
|
||||||
token: directRecipient.token,
|
|
||||||
fieldId: field.id,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
|
|
||||||
updatedFields[index] = {
|
|
||||||
...field,
|
|
||||||
customText: value,
|
|
||||||
inserted: true,
|
|
||||||
signedValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setLocalFields(updatedFields);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
@@ -232,8 +191,6 @@ export const DirectTemplateSigningForm = ({
|
|||||||
onSignField={onSignField}
|
onSignField={onSignField}
|
||||||
onUnsignField={onUnsignField}
|
onUnsignField={onUnsignField}
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
@@ -378,15 +335,19 @@ export const DirectTemplateSigningForm = ({
|
|||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<SignaturePadDialog
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
className="mt-2"
|
<CardContent className="p-0">
|
||||||
|
<SignaturePad
|
||||||
|
className="h-44 w-full"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={signature ?? ''}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => setSignature(value)}
|
onChange={(value) => {
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
setSignature(value);
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
}}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,16 +97,6 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
// Do nothing, this should only happen when the user clicks the field, but
|
|
||||||
// misses the checkbox which triggers this callback.
|
|
||||||
if (checkedValues.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLengthConditionMet) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: TSignFieldWithTokenMutationSchema = {
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
@@ -204,30 +194,18 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
setCheckedValues(updatedValues);
|
setCheckedValues(updatedValues);
|
||||||
|
|
||||||
const removePayload: TRemovedSignedFieldWithTokenMutationSchema = {
|
await removeSignedFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
};
|
});
|
||||||
|
|
||||||
if (onUnsignField) {
|
if (updatedValues.length > 0) {
|
||||||
await onUnsignField(removePayload);
|
await signFieldWithToken({
|
||||||
} else {
|
|
||||||
await removeSignedFieldWithToken(removePayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatedValues.length > 0 && shouldAutoSignField) {
|
|
||||||
const signPayload: TSignFieldWithTokenMutationSchema = {
|
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: toCheckboxValue(updatedValues),
|
value: toCheckboxValue(updatedValues),
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
};
|
});
|
||||||
|
|
||||||
if (onSignField) {
|
|
||||||
await onSignField(signPayload);
|
|
||||||
} else {
|
|
||||||
await signFieldWithToken(signPayload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -276,26 +254,21 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
{validationSign?.label} {checkboxValidationLength}
|
{validationSign?.label} {checkboxValidationLength}
|
||||||
</FieldToolTip>
|
</FieldToolTip>
|
||||||
)}
|
)}
|
||||||
<div className="z-50 my-0.5 flex flex-col gap-y-1">
|
<div className="z-50 flex flex-col gap-y-2">
|
||||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||||
const itemValue = item.value || `empty-value-${item.id}`;
|
const itemValue = item.value || `empty-value-${item.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center gap-x-1.5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-3 w-3"
|
className="h-4 w-4"
|
||||||
id={`checkbox-${field.id}-${item.id}`}
|
id={`checkbox-${index}`}
|
||||||
checked={checkedValues.includes(itemValue)}
|
checked={checkedValues.includes(itemValue)}
|
||||||
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
|
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
|
||||||
/>
|
/>
|
||||||
{!item.value.includes('empty-value-') && item.value && (
|
<Label htmlFor={`checkbox-${index}`}>
|
||||||
<Label
|
{item.value.includes('empty-value-') ? '' : item.value}
|
||||||
htmlFor={`checkbox-${field.id}-${item.id}`}
|
|
||||||
className="text-foreground ml-1.5 text-xs font-normal"
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -304,27 +277,22 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<div className="my-0.5 flex flex-col gap-y-1">
|
<div className="flex flex-col gap-y-1">
|
||||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||||
const itemValue = item.value || `empty-value-${item.id}`;
|
const itemValue = item.value || `empty-value-${item.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center gap-x-1.5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
id={`checkbox-${field.id}-${item.id}`}
|
id={`checkbox-${index}`}
|
||||||
checked={parsedCheckedValues.includes(itemValue)}
|
checked={parsedCheckedValues.includes(itemValue)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
||||||
/>
|
/>
|
||||||
{!item.value.includes('empty-value-') && item.value && (
|
<Label htmlFor={`checkbox-${index}`} className="text-xs">
|
||||||
<Label
|
{item.value.includes('empty-value-') ? '' : item.value}
|
||||||
htmlFor={`checkbox-${field.id}-${item.id}`}
|
|
||||||
className="text-foreground ml-1.5 text-xs font-normal"
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field } from '@prisma/client';
|
import type { Field } from '@prisma/client';
|
||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -17,15 +13,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
@@ -34,22 +21,10 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
onSignatureComplete: () => void | Promise<void>;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
allowDictateNextSigner?: boolean;
|
|
||||||
defaultNextSigner?: {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const ZNextSignerFormSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
email: z.string().email('Invalid email address'),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
|
||||||
|
|
||||||
export const DocumentSigningCompleteDialog = ({
|
export const DocumentSigningCompleteDialog = ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
@@ -59,54 +34,19 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
allowDictateNextSigner = false,
|
|
||||||
defaultNextSigner,
|
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
|
||||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (form.formState.isSubmitting || !isComplete) {
|
if (isSubmitting || !isComplete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (open) {
|
|
||||||
form.reset({
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsEditingNextSigner(false);
|
|
||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
|
||||||
console.log('data', data);
|
|
||||||
console.log('form.formState.errors', form.formState.errors);
|
|
||||||
try {
|
|
||||||
if (allowDictateNextSigner && data.name && data.email) {
|
|
||||||
await onSignatureComplete({ name: data.name, email: data.email });
|
|
||||||
} else {
|
|
||||||
await onSignatureComplete();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error completing signature:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -118,36 +58,21 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{match({ isComplete, role })
|
{isComplete ? <Trans>Complete</Trans> : <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>
|
|
||||||
))
|
|
||||||
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="text-foreground text-xl font-semibold">
|
||||||
{match(role)
|
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{match(role)
|
{role === RecipientRole.VIEWER && (
|
||||||
.with(RecipientRole.VIEWER, () => (
|
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
@@ -160,8 +85,8 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<br /> Are you sure?
|
<br /> Are you sure?
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
))
|
)}
|
||||||
.with(RecipientRole.SIGNER, () => (
|
{role === RecipientRole.SIGNER && (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
@@ -174,8 +99,8 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<br /> Are you sure?
|
<br /> Are you sure?
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
))
|
)}
|
||||||
.with(RecipientRole.APPROVER, () => (
|
{role === RecipientRole.APPROVER && (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
@@ -188,126 +113,37 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<br /> Are you sure?
|
<br /> Are you sure?
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete viewing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allowDictateNextSigner && (
|
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
|
||||||
{!isEditingNextSigner && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
The next recipient to sign this document will be{' '}
|
|
||||||
<span className="font-semibold">{form.watch('name')}</span> (
|
|
||||||
<span className="font-semibold">{form.watch('email')}</span>).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="mt-2"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<Trans>Update Recipient</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditingNextSigner && (
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's name"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="email"
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's email"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setShowDialog(false)}
|
onClick={() => {
|
||||||
disabled={form.formState.isSubmitting}
|
setShowDialog(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!isComplete || !isNextSignerValid}
|
disabled={!isComplete}
|
||||||
loading={form.formState.isSubmitting}
|
loading={isSubmitting}
|
||||||
|
onClick={onSignatureComplete}
|
||||||
>
|
>
|
||||||
{match(role)
|
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
|
||||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const DocumentSigningDateField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||||
<Trans>Date</Trans>
|
<Trans>Date</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -151,10 +151,12 @@ export const DocumentSigningDateField = ({
|
|||||||
<div className="flex h-full w-full items-center">
|
<div className="flex h-full w-full items-center">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground w-full whitespace-nowrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||||
{
|
{
|
||||||
'!text-center': parsedFieldMeta?.textAlign === 'center',
|
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||||
'!text-right': parsedFieldMeta?.textAlign === 'right',
|
'text-center':
|
||||||
|
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||||
|
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -177,11 +177,15 @@ export const DocumentSigningDropdownField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p className="group-hover:text-primary text-foreground flex flex-col items-center justify-center duration-200">
|
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
|
||||||
<Select value={localChoice} onValueChange={handleSelectItem}>
|
<Select value={localChoice} onValueChange={handleSelectItem}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground z-10 h-full w-full border-none ring-0 focus:border-none focus:ring-0',
|
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0',
|
||||||
|
{
|
||||||
|
'hover:text-red-300': parsedFieldMeta.required,
|
||||||
|
'hover:text-yellow-300': !parsedFieldMeta.required,
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
@@ -201,7 +205,7 @@ export const DocumentSigningDropdownField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<p className="text-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||||
{field.customText}
|
{field.customText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@@ -13,14 +14,10 @@ import type {
|
|||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
TSignFieldWithTokenMutationSchema,
|
TSignFieldWithTokenMutationSchema,
|
||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
import {
|
|
||||||
DocumentSigningFieldsInserted,
|
|
||||||
DocumentSigningFieldsLoader,
|
|
||||||
DocumentSigningFieldsUninserted,
|
|
||||||
} from './document-signing-fields';
|
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
@@ -123,18 +120,34 @@ export const DocumentSigningEmailField = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
||||||
{isLoading && <DocumentSigningFieldsLoader />}
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<DocumentSigningFieldsUninserted>
|
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||||
<Trans>Email</Trans>
|
<Trans>Email</Trans>
|
||||||
</DocumentSigningFieldsUninserted>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
<div className="flex h-full w-full items-center">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||||
|
{
|
||||||
|
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||||
|
'text-center':
|
||||||
|
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||||
|
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
{field.customText}
|
{field.customText}
|
||||||
</DocumentSigningFieldsInserted>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</DocumentSigningFieldContainer>
|
</DocumentSigningFieldContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import React from 'react';
|
|||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import { TooltipArrow } from '@radix-ui/react-tooltip';
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
@@ -130,51 +128,57 @@ export const DocumentSigningFieldContainer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('[container-type:size]')}>
|
<div className={cn('[container-type:size]', { group: type === 'Checkbox' })}>
|
||||||
<FieldRootContainer color={RECIPIENT_COLOR_STYLES.green} field={field}>
|
<FieldRootContainer field={field}>
|
||||||
{!field.inserted && !loading && !readOnlyField && (
|
{!field.inserted && !loading && !readOnlyField && (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
className="absolute inset-0 z-10 h-full w-full rounded-md border"
|
||||||
onClick={async () => handleInsertField()}
|
onClick={async () => handleInsertField()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{readOnlyField && (
|
{readOnlyField && (
|
||||||
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
|
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
|
||||||
<span className="bg-foreground/50 text-background rounded-xl p-2">
|
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2">
|
||||||
<Trans>Read only field</Trans>
|
<Trans>Read only field</Trans>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
|
||||||
|
onClick={onRemoveSignedFieldClick}
|
||||||
|
>
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||||
<button
|
<button
|
||||||
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||||
onClick={() => void onClearCheckBoxValues(type)}
|
onClick={() => void onClearCheckBoxValues(type)}
|
||||||
>
|
>
|
||||||
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||||
<Tooltip delayDuration={0}>
|
<button
|
||||||
<TooltipTrigger asChild>
|
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
|
||||||
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
|
onClick={onRemoveSignedFieldClick}
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent
|
|
||||||
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
|
|
||||||
sideOffset={2}
|
|
||||||
>
|
>
|
||||||
{tooltipText && <p>{tooltipText}</p>}
|
|
||||||
|
|
||||||
<Trans>Remove</Trans>
|
<Trans>Remove</Trans>
|
||||||
<TooltipArrow />
|
</button>
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export const DocumentSigningFieldsLoader = () => {
|
|
||||||
return (
|
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type DocumentSigningFieldsInsertedProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The text alignment of the field.
|
|
||||||
*
|
|
||||||
* Defaults to left.
|
|
||||||
*/
|
|
||||||
textAlign?: 'left' | 'center' | 'right';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentSigningFieldsInserted = ({
|
|
||||||
children,
|
|
||||||
textAlign = 'left',
|
|
||||||
}: DocumentSigningFieldsInsertedProps) => {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
|
||||||
{
|
|
||||||
'!text-center': textAlign === 'center',
|
|
||||||
'!text-right': textAlign === 'right',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useId, useMemo, useState } from 'react';
|
import { useId, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@@ -20,26 +18,17 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import {
|
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
|
||||||
AssistantConfirmationDialog,
|
|
||||||
type NextSigner,
|
|
||||||
} from '../../dialogs/assistant-confirmation-dialog';
|
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
|
|
||||||
export const ZSigningFormSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required').optional(),
|
|
||||||
email: z.string().email('Invalid email address').optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TSigningFormSchema = z.infer<typeof ZSigningFormSchema>;
|
|
||||||
|
|
||||||
export type DocumentSigningFormProps = {
|
export type DocumentSigningFormProps = {
|
||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
@@ -70,7 +59,8 @@ export const DocumentSigningForm = ({
|
|||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
|
||||||
|
useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||||
@@ -85,9 +75,7 @@ export const DocumentSigningForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, formState } = useForm<TSigningFormSchema>({
|
const { handleSubmit, formState } = useForm();
|
||||||
resolver: zodResolver(ZSigningFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
||||||
@@ -112,32 +100,20 @@ export const DocumentSigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async (data: TSigningFormSchema) => {
|
const onFormSubmit = async () => {
|
||||||
try {
|
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
|
if (hasSignatureField && !signatureValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
if (!isFieldsValid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSigner =
|
await completeDocument();
|
||||||
data.email && data.name
|
|
||||||
? {
|
|
||||||
email: data.email,
|
|
||||||
name: data.name,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await completeDocument(undefined, nextSigner);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: error instanceof Error ? error.message : 'An error occurred while signing',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
@@ -148,11 +124,11 @@ export const DocumentSigningForm = ({
|
|||||||
setIsConfirmationDialogOpen(true);
|
setIsConfirmationDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
|
const handleAssistantConfirmDialogSubmit = async () => {
|
||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@@ -165,18 +141,12 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (
|
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
||||||
authOptions?: TRecipientActionAuth,
|
await completeDocumentWithToken({
|
||||||
nextSigner?: { email: string; name: string },
|
|
||||||
) => {
|
|
||||||
const payload = {
|
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
authOptions,
|
authOptions,
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
});
|
||||||
};
|
|
||||||
|
|
||||||
await completeDocumentWithToken(payload);
|
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
@@ -191,31 +161,6 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextRecipient = useMemo(() => {
|
|
||||||
if (
|
|
||||||
!document.documentMeta?.signingOrder ||
|
|
||||||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedRecipients = allRecipients.sort((a, b) => {
|
|
||||||
// Sort by signingOrder first (nulls last), then by id
|
|
||||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
|
||||||
if (a.signingOrder === null) return 1;
|
|
||||||
if (b.signingOrder === null) return -1;
|
|
||||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
|
||||||
return a.signingOrder - b.signingOrder;
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
|
||||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
|
||||||
? sortedRecipients[currentIndex + 1]
|
|
||||||
: undefined;
|
|
||||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
|
||||||
|
|
||||||
console.log('nextRecipient', nextRecipient);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -265,19 +210,12 @@ export const DocumentSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
|
||||||
await completeDocument(undefined, nextSigner);
|
|
||||||
}}
|
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
disabled={!isRecipientsTurn}
|
||||||
defaultNextSigner={
|
|
||||||
nextRecipient
|
|
||||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,14 +306,6 @@ export const DocumentSigningForm = ({
|
|||||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||||
isSubmitting={isAssistantSubmitting}
|
isSubmitting={isAssistantSubmitting}
|
||||||
allowDictateNextSigner={
|
|
||||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
|
||||||
}
|
|
||||||
defaultNextSigner={
|
|
||||||
nextRecipient
|
|
||||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
@@ -383,11 +313,7 @@ export const DocumentSigningForm = ({
|
|||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
|
||||||
<Trans>Please review the document before approving.</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Please review the document before signing.</Trans>
|
<Trans>Please review the document before signing.</Trans>
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<hr className="border-border mb-8 mt-4" />
|
||||||
@@ -411,27 +337,41 @@ export const DocumentSigningForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && (
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Signature">
|
<Label htmlFor="Signature">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<SignaturePadDialog
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
className="mt-2"
|
<CardContent className="p-0">
|
||||||
|
<SignaturePad
|
||||||
|
className="h-44 w-full"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={signature ?? ''}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
onValidityChange={(isValid) => {
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
setSignatureValid(isValid);
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
}}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
onChange={(value) => {
|
||||||
|
if (signatureValid) {
|
||||||
|
setSignature(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{hasSignatureField && !signatureValid && (
|
||||||
|
<div className="text-destructive mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
|
Signature is too small. Please provide a more complete signature.
|
||||||
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
@@ -444,25 +384,16 @@ export const DocumentSigningForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
onSignatureComplete={async (nextSigner) => {
|
|
||||||
await completeDocument(undefined, nextSigner);
|
|
||||||
}}
|
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
allowDictateNextSigner={
|
disabled={!isRecipientsTurn}
|
||||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
|
||||||
}
|
|
||||||
defaultNextSigner={
|
|
||||||
nextRecipient
|
|
||||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { ZInitialsFieldMeta } from '@documenso/lib/types/field-meta';
|
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -17,11 +17,6 @@ import type {
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
import {
|
|
||||||
DocumentSigningFieldsInserted,
|
|
||||||
DocumentSigningFieldsLoader,
|
|
||||||
DocumentSigningFieldsUninserted,
|
|
||||||
} from './document-signing-fields';
|
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
@@ -55,9 +50,6 @@ export const DocumentSigningInitialsField = ({
|
|||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
||||||
|
|
||||||
const safeFieldMeta = ZInitialsFieldMeta.safeParse(field.fieldMeta);
|
|
||||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
|
||||||
|
|
||||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
const value = initials ?? '';
|
const value = initials ?? '';
|
||||||
@@ -130,18 +122,22 @@ export const DocumentSigningInitialsField = ({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
type="Initials"
|
type="Initials"
|
||||||
>
|
>
|
||||||
{isLoading && <DocumentSigningFieldsLoader />}
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<DocumentSigningFieldsUninserted>
|
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||||
<Trans>Initials</Trans>
|
<Trans>Initials</Trans>
|
||||||
</DocumentSigningFieldsUninserted>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||||
{field.customText}
|
{field.customText}
|
||||||
</DocumentSigningFieldsInserted>
|
</p>
|
||||||
)}
|
)}
|
||||||
</DocumentSigningFieldContainer>
|
</DocumentSigningFieldContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@@ -15,6 +16,7 @@ import type {
|
|||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
TSignFieldWithTokenMutationSchema,
|
TSignFieldWithTokenMutationSchema,
|
||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@@ -23,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
import {
|
|
||||||
DocumentSigningFieldsInserted,
|
|
||||||
DocumentSigningFieldsLoader,
|
|
||||||
DocumentSigningFieldsUninserted,
|
|
||||||
} from './document-signing-fields';
|
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
@@ -169,18 +166,34 @@ export const DocumentSigningNameField = ({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
type="Name"
|
type="Name"
|
||||||
>
|
>
|
||||||
{isLoading && <DocumentSigningFieldsLoader />}
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<DocumentSigningFieldsUninserted>
|
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||||
<Trans>Name</Trans>
|
<Trans>Name</Trans>
|
||||||
</DocumentSigningFieldsUninserted>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
<div className="flex h-full w-full items-center">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||||
|
{
|
||||||
|
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||||
|
'text-center':
|
||||||
|
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||||
|
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
{field.customText}
|
{field.customText}
|
||||||
</DocumentSigningFieldsInserted>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
||||||
@@ -189,7 +202,7 @@ export const DocumentSigningNameField = ({
|
|||||||
<Trans>
|
<Trans>
|
||||||
Sign as
|
Sign as
|
||||||
<div>
|
<div>
|
||||||
{recipient.name} <div className="text-foreground">({recipient.email})</div>
|
{recipient.name} <div className="text-muted-foreground">({recipient.email})</div>
|
||||||
</div>
|
</div>
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@@ -211,7 +224,7 @@ export const DocumentSigningNameField = ({
|
|||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowFullNameModal(false);
|
setShowFullNameModal(false);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Hash, Loader } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||||
@@ -24,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
import {
|
|
||||||
DocumentSigningFieldsInserted,
|
|
||||||
DocumentSigningFieldsLoader,
|
|
||||||
DocumentSigningFieldsUninserted,
|
|
||||||
} from './document-signing-fields';
|
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
type ValidationErrors = {
|
type ValidationErrors = {
|
||||||
@@ -249,16 +245,45 @@ export const DocumentSigningNumberField = ({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
type="Number"
|
type="Number"
|
||||||
>
|
>
|
||||||
{isLoading && <DocumentSigningFieldsLoader />}
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<DocumentSigningFieldsUninserted>{fieldDisplayName}</DocumentSigningFieldsUninserted>
|
<p
|
||||||
|
className={cn(
|
||||||
|
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
|
||||||
|
{
|
||||||
|
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
|
||||||
|
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-x-1">
|
||||||
|
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
|
||||||
|
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
<div className="flex h-full w-full items-center">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||||
|
{
|
||||||
|
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||||
|
'text-center':
|
||||||
|
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||||
|
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
{field.customText}
|
{field.customText}
|
||||||
</DocumentSigningFieldsInserted>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
|
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
|
||||||
@@ -314,7 +339,7 @@ export const DocumentSigningNumberField = ({
|
|||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowNumberModal(false);
|
setShowNumberModal(false);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
@@ -37,12 +36,13 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
|
|||||||
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
||||||
|
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningPageViewProps = {
|
export type SigningPageViewProps = {
|
||||||
recipient: RecipientWithFields;
|
|
||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: CompletedField[];
|
completedFields: CompletedField[];
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
@@ -50,13 +50,13 @@ export type DocumentSigningPageViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageView = ({
|
||||||
recipient,
|
|
||||||
document,
|
document,
|
||||||
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: DocumentSigningPageViewProps) => {
|
}: SigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
@@ -157,7 +157,7 @@ export const DocumentSigningPageView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentReadOnlyFields fields={completedFields} showRecipientTooltip={true} />
|
<DocumentReadOnlyFields fields={completedFields} />
|
||||||
|
|
||||||
{recipient.role !== RecipientRole.ASSISTANT && (
|
{recipient.role !== RecipientRole.ASSISTANT && (
|
||||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||||
@@ -177,8 +177,6 @@ export const DocumentSigningPageView = ({
|
|||||||
key={field.id}
|
key={field.id}
|
||||||
field={field}
|
field={field}
|
||||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createContext, useContext, useState } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
|
||||||
|
|
||||||
export type DocumentSigningContextValue = {
|
export type DocumentSigningContextValue = {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
@@ -9,6 +7,8 @@ export type DocumentSigningContextValue = {
|
|||||||
setEmail: (_value: string) => void;
|
setEmail: (_value: string) => void;
|
||||||
signature: string | null;
|
signature: string | null;
|
||||||
setSignature: (_value: string | null) => void;
|
setSignature: (_value: string | null) => void;
|
||||||
|
signatureValid: boolean;
|
||||||
|
setSignatureValid: (_valid: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null);
|
const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null);
|
||||||
@@ -31,9 +31,6 @@ export interface DocumentSigningProviderProps {
|
|||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
signature?: string | null;
|
signature?: string | null;
|
||||||
typedSignatureEnabled?: boolean;
|
|
||||||
uploadSignatureEnabled?: boolean;
|
|
||||||
drawSignatureEnabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,31 +38,18 @@ export const DocumentSigningProvider = ({
|
|||||||
fullName: initialFullName,
|
fullName: initialFullName,
|
||||||
email: initialEmail,
|
email: initialEmail,
|
||||||
signature: initialSignature,
|
signature: initialSignature,
|
||||||
typedSignatureEnabled = true,
|
|
||||||
uploadSignatureEnabled = true,
|
|
||||||
drawSignatureEnabled = true,
|
|
||||||
children,
|
children,
|
||||||
}: DocumentSigningProviderProps) => {
|
}: DocumentSigningProviderProps) => {
|
||||||
const [fullName, setFullName] = useState(initialFullName || '');
|
const [fullName, setFullName] = useState(initialFullName || '');
|
||||||
const [email, setEmail] = useState(initialEmail || '');
|
const [email, setEmail] = useState(initialEmail || '');
|
||||||
|
const [signature, setSignature] = useState(initialSignature || null);
|
||||||
|
const [signatureValid, setSignatureValid] = useState(true);
|
||||||
|
|
||||||
// Ensure the user signature doesn't show up if it's not allowed.
|
useEffect(() => {
|
||||||
const [signature, setSignature] = useState(
|
if (initialSignature) {
|
||||||
(() => {
|
setSignature(initialSignature);
|
||||||
const sig = initialSignature || '';
|
|
||||||
const isBase64 = isBase64Image(sig);
|
|
||||||
|
|
||||||
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
|
|
||||||
return sig;
|
|
||||||
}
|
}
|
||||||
|
}, [initialSignature]);
|
||||||
if (!isBase64 && typedSignatureEnabled) {
|
|
||||||
return sig;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningContext.Provider
|
<DocumentSigningContext.Provider
|
||||||
@@ -76,6 +60,8 @@ export const DocumentSigningProvider = ({
|
|||||||
setEmail,
|
setEmail,
|
||||||
signature,
|
signature,
|
||||||
setSignature,
|
setSignature,
|
||||||
|
signatureValid,
|
||||||
|
setSignatureValid,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@@ -20,7 +21,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
import { DocumentSigningFieldsLoader } from './document-signing-fields';
|
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningRadioFieldProps = {
|
export type DocumentSigningRadioFieldProps = {
|
||||||
@@ -150,52 +150,44 @@ export const DocumentSigningRadioField = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
|
<DocumentSigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
|
||||||
{isLoading && <DocumentSigningFieldsLoader />}
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<RadioGroup
|
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10">
|
||||||
onValueChange={(value) => handleSelectItem(value)}
|
|
||||||
className="z-10 my-0.5 gap-y-1"
|
|
||||||
>
|
|
||||||
{values?.map((item, index) => (
|
{values?.map((item, index) => (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center gap-x-1.5">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
className="h-3 w-3 shrink-0"
|
className="h-4 w-4 shrink-0"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
id={`option-${field.id}-${item.id}`}
|
id={`option-${index}`}
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
/>
|
/>
|
||||||
{!item.value.includes('empty-value-') && item.value && (
|
|
||||||
<Label
|
<Label htmlFor={`option-${index}`}>
|
||||||
htmlFor={`option-${field.id}-${item.id}`}
|
{item.value.includes('empty-value-') ? '' : item.value}
|
||||||
className="text-foreground ml-1.5 text-xs font-normal"
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<RadioGroup className="my-0.5 gap-y-1">
|
<RadioGroup className="gap-y-1">
|
||||||
{values?.map((item, index) => (
|
{values?.map((item, index) => (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center gap-x-1.5">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
value={item.value}
|
value={item.value}
|
||||||
id={`option-${field.id}-${item.id}`}
|
id={`option-${index}`}
|
||||||
checked={item.value === field.customText}
|
checked={item.value === field.customText}
|
||||||
/>
|
/>
|
||||||
{!item.value.includes('empty-value-') && item.value && (
|
<Label htmlFor={`option-${index}`} className="text-xs">
|
||||||
<Label
|
{item.value.includes('empty-value-') ? '' : item.value}
|
||||||
htmlFor={`option-${field.id}-${item.id}`}
|
|
||||||
className="text-foreground ml-1.5 text-xs font-normal"
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZRejectDocumentFormSchema = z.object({
|
const ZRejectDocumentFormSchema = z.object({
|
||||||
reason: z.string().max(500, msg`Reason must be less than 500 characters`),
|
reason: z
|
||||||
|
.string()
|
||||||
|
.min(5, msg`Please provide a reason`)
|
||||||
|
.max(500, msg`Reason must be less than 500 characters`),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -28,14 +29,11 @@ import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
|||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
||||||
|
|
||||||
export type DocumentSigningSignatureFieldProps = {
|
export type DocumentSigningSignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
|
||||||
drawSignatureEnabled?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningSignatureField = ({
|
export const DocumentSigningSignatureField = ({
|
||||||
@@ -43,8 +41,6 @@ export const DocumentSigningSignatureField = ({
|
|||||||
onSignField,
|
onSignField,
|
||||||
onUnsignField,
|
onUnsignField,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
|
||||||
drawSignatureEnabled,
|
|
||||||
}: DocumentSigningSignatureFieldProps) => {
|
}: DocumentSigningSignatureFieldProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -56,8 +52,12 @@ export const DocumentSigningSignatureField = ({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [fontSize, setFontSize] = useState(2);
|
const [fontSize, setFontSize] = useState(2);
|
||||||
|
|
||||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
const {
|
||||||
useRequiredDocumentSigningContext();
|
signature: providedSignature,
|
||||||
|
setSignature: setProvidedSignature,
|
||||||
|
signatureValid,
|
||||||
|
setSignatureValid,
|
||||||
|
} = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
}, [field.inserted, signature?.signatureImageAsBase64]);
|
}, [field.inserted, signature?.signatureImageAsBase64]);
|
||||||
|
|
||||||
const onPreSign = () => {
|
const onPreSign = () => {
|
||||||
if (!providedSignature) {
|
if (!providedSignature || !signatureValid) {
|
||||||
setShowSignatureModal(true);
|
setShowSignatureModal(true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,6 @@ export const DocumentSigningSignatureField = ({
|
|||||||
const onDialogSignClick = () => {
|
const onDialogSignClick = () => {
|
||||||
setShowSignatureModal(false);
|
setShowSignatureModal(false);
|
||||||
setProvidedSignature(localSignature);
|
setProvidedSignature(localSignature);
|
||||||
|
|
||||||
if (!localSignature) {
|
if (!localSignature) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,14 +116,14 @@ export const DocumentSigningSignatureField = ({
|
|||||||
try {
|
try {
|
||||||
const value = signature || providedSignature;
|
const value = signature || providedSignature;
|
||||||
|
|
||||||
if (!value) {
|
if (!value || (signature && !signatureValid)) {
|
||||||
setShowSignatureModal(true);
|
setShowSignatureModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTypedSignature = !value.startsWith('data:image');
|
const isTypedSignature = !value.startsWith('data:image');
|
||||||
|
|
||||||
if (isTypedSignature && typedSignatureEnabled === false) {
|
if (isTypedSignature && !typedSignatureEnabled) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
title: _(msg`Error`),
|
||||||
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
|
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
|
||||||
@@ -242,7 +241,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{state === 'empty' && (
|
{state === 'empty' && (
|
||||||
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
|
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -259,7 +258,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
||||||
<p
|
<p
|
||||||
ref={signatureRef}
|
ref={signatureRef}
|
||||||
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
|
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
|
||||||
style={{ fontSize: `${fontSize}rem` }}
|
style={{ fontSize: `${fontSize}rem` }}
|
||||||
>
|
>
|
||||||
{signature?.typedSignature}
|
{signature?.typedSignature}
|
||||||
@@ -276,14 +275,29 @@ export const DocumentSigningSignatureField = ({
|
|||||||
</Trans>
|
</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
|
<div className="">
|
||||||
|
<Label htmlFor="signature">
|
||||||
|
<Trans>Signature</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="border-border mt-2 rounded-md border">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="mt-2"
|
id="signature"
|
||||||
value={localSignature ?? ''}
|
className="h-44 w-full"
|
||||||
onChange={({ value }) => setLocalSignature(value)}
|
onChange={(value) => setLocalSignature(value)}
|
||||||
typedSignatureEnabled={typedSignatureEnabled}
|
allowTypedSignature={typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
onValidityChange={(isValid) => {
|
||||||
drawSignatureEnabled={drawSignatureEnabled}
|
setSignatureValid(isValid);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!signatureValid && (
|
||||||
|
<div className="text-destructive mt-2 text-sm">
|
||||||
|
<Trans>Signature is too small. Please provide a more complete signature.</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DocumentSigningDisclosure />
|
<DocumentSigningDisclosure />
|
||||||
|
|
||||||
@@ -291,7 +305,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSignatureModal(false);
|
setShowSignatureModal(false);
|
||||||
@@ -303,7 +317,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localSignature}
|
disabled={!localSignature || !signatureValid}
|
||||||
onClick={() => onDialogSignClick()}
|
onClick={() => onDialogSignClick()}
|
||||||
>
|
>
|
||||||
<Trans>Sign</Trans>
|
<Trans>Sign</Trans>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { Loader, Type } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||||
@@ -24,11 +25,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
||||||
import {
|
|
||||||
DocumentSigningFieldsInserted,
|
|
||||||
DocumentSigningFieldsLoader,
|
|
||||||
DocumentSigningFieldsUninserted,
|
|
||||||
} from './document-signing-fields';
|
|
||||||
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningTextFieldProps = {
|
export type DocumentSigningTextFieldProps = {
|
||||||
@@ -252,20 +248,49 @@ export const DocumentSigningTextField = ({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
type="Text"
|
type="Text"
|
||||||
>
|
>
|
||||||
{isLoading && <DocumentSigningFieldsLoader />}
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<DocumentSigningFieldsUninserted>
|
<p
|
||||||
|
className={cn(
|
||||||
|
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
|
||||||
|
{
|
||||||
|
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
|
||||||
|
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-x-1">
|
||||||
|
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
|
||||||
|
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
||||||
{fieldDisplayName || <Trans>Text</Trans>}
|
{fieldDisplayName || <Trans>Text</Trans>}
|
||||||
</DocumentSigningFieldsUninserted>
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
<div className="flex h-full w-full items-center">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||||
|
{
|
||||||
|
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||||
|
'text-center':
|
||||||
|
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||||
|
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
{field.customText.length < 20
|
{field.customText.length < 20
|
||||||
? field.customText
|
? field.customText
|
||||||
: field.customText.substring(0, 20) + '...'}
|
: field.customText.substring(0, 20) + '...'}
|
||||||
</DocumentSigningFieldsInserted>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
||||||
@@ -279,9 +304,11 @@ export const DocumentSigningTextField = ({
|
|||||||
id="custom-text"
|
id="custom-text"
|
||||||
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
|
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
|
||||||
className={cn('mt-2 w-full rounded-md', {
|
className={cn('mt-2 w-full rounded-md', {
|
||||||
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||||
userInputHasErrors,
|
userInputHasErrors,
|
||||||
'text-center': parsedFieldMeta?.textAlign === 'center',
|
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||||
|
'text-center':
|
||||||
|
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
})}
|
})}
|
||||||
value={localText}
|
value={localText}
|
||||||
@@ -327,8 +354,8 @@ export const DocumentSigningTextField = ({
|
|||||||
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCustomTextModal(false);
|
setShowCustomTextModal(false);
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -77,7 +76,7 @@ export const DocumentCertificateDownloadButton = ({
|
|||||||
className={cn('w-full sm:w-auto', className)}
|
className={cn('w-full sm:w-auto', className)}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!isDocumentCompleted(documentStatus)}
|
disabled={documentStatus !== DocumentStatus.COMPLETED}
|
||||||
onClick={() => void onDownloadCertificatesClick()}
|
onClick={() => void onDownloadCertificatesClick()}
|
||||||
>
|
>
|
||||||
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
|
||||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
@@ -72,7 +71,7 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const { recipients, fields } = document;
|
const { recipients, fields } = document;
|
||||||
|
|
||||||
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
|
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
@@ -175,7 +174,7 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
const { timezone, dateFormat, redirectUrl, language } = data.meta;
|
||||||
|
|
||||||
await updateDocument({
|
await updateDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@@ -191,9 +190,6 @@ export const DocumentEditForm = ({
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
language: isValidLanguageCode(language) ? language : undefined,
|
language: isValidLanguageCode(language) ? language : undefined,
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,13 +213,6 @@ export const DocumentEditForm = ({
|
|||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateDocument({
|
|
||||||
documentId: document.id,
|
|
||||||
meta: {
|
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
setRecipients({
|
setRecipients({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipients: data.signers.map((signer) => ({
|
recipients: data.signers.map((signer) => ({
|
||||||
@@ -253,6 +242,14 @@ export const DocumentEditForm = ({
|
|||||||
fields: data.fields,
|
fields: data.fields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
|
||||||
|
meta: {
|
||||||
|
typedSignatureEnabled: data.typedSignatureEnabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Clear all field data from localStorage
|
// Clear all field data from localStorage
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
@@ -368,7 +365,6 @@ export const DocumentEditForm = ({
|
|||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
@@ -382,6 +378,7 @@ export const DocumentEditForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
teamId={team?.id}
|
teamId={team?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -33,7 +32,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
|
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = document.status === DocumentStatus.PENDING;
|
||||||
const isComplete = isDocumentCompleted(document);
|
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@@ -64,7 +63,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = document.status === DocumentStatus.PENDING;
|
||||||
const isDeleted = document.deletedAt !== null;
|
const isDeleted = document.deletedAt !== null;
|
||||||
const isComplete = isDocumentCompleted(document);
|
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { Link } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
@@ -49,7 +48,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
<Trans>Recipients</Trans>
|
<Trans>Recipients</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{!isDocumentCompleted(document.status) && (
|
{document.status !== DocumentStatus.COMPLETED && (
|
||||||
<Link
|
<Link
|
||||||
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||||
title={_(msg`Modify recipients`)}
|
title={_(msg`Modify recipients`)}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import type { DocumentMeta } from '@prisma/client';
|
||||||
|
import { FieldType, SigningStatus } from '@prisma/client';
|
||||||
|
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
convertToLocalSystemFormat,
|
||||||
|
} from '@documenso/lib/constants/date-formats';
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
|
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
|
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
|
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
export type DocumentReadOnlyFieldsProps = {
|
||||||
|
fields: DocumentField[];
|
||||||
|
documentMeta?: DocumentMeta;
|
||||||
|
showFieldStatus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentReadOnlyFields = ({
|
||||||
|
documentMeta,
|
||||||
|
fields,
|
||||||
|
showFieldStatus = true,
|
||||||
|
}: DocumentReadOnlyFieldsProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const handleHideField = (fieldId: string) => {
|
||||||
|
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{fields.map(
|
||||||
|
(field) =>
|
||||||
|
!hiddenFieldIds[field.secondaryId] && (
|
||||||
|
<FieldRootContainer
|
||||||
|
field={field}
|
||||||
|
key={field.id}
|
||||||
|
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
|
||||||
|
>
|
||||||
|
<div className="absolute -right-3 -top-3">
|
||||||
|
<PopoverHover
|
||||||
|
trigger={
|
||||||
|
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
|
||||||
|
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
|
||||||
|
{extractInitials(field.recipient.name || field.recipient.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
contentProps={{
|
||||||
|
className: 'relative flex w-fit flex-col p-4 text-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showFieldStatus && (
|
||||||
|
<Badge
|
||||||
|
className="mx-auto mb-1 py-0.5"
|
||||||
|
variant={
|
||||||
|
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||||
|
<>
|
||||||
|
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||||
|
<Trans>Signed</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
<Trans>Pending</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center font-semibold">
|
||||||
|
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||||
|
{field.recipient.name
|
||||||
|
? `${field.recipient.name} (${field.recipient.email})`
|
||||||
|
: field.recipient.email}{' '}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
|
||||||
|
onClick={() => handleHideField(field.secondaryId)}
|
||||||
|
title="Hide field"
|
||||||
|
>
|
||||||
|
<EyeOffIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</PopoverHover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
|
||||||
|
{field.recipient.signingStatus === SigningStatus.SIGNED &&
|
||||||
|
match(field)
|
||||||
|
.with({ type: FieldType.SIGNATURE }, (field) =>
|
||||||
|
field.signature?.signatureImageAsBase64 ? (
|
||||||
|
<img
|
||||||
|
src={field.signature.signatureImageAsBase64}
|
||||||
|
alt="Signature"
|
||||||
|
className="h-full w-full object-contain dark:invert"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl">
|
||||||
|
{field.signature?.typedSignature}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
{
|
||||||
|
type: P.union(
|
||||||
|
FieldType.NAME,
|
||||||
|
FieldType.INITIALS,
|
||||||
|
FieldType.EMAIL,
|
||||||
|
FieldType.NUMBER,
|
||||||
|
FieldType.RADIO,
|
||||||
|
FieldType.CHECKBOX,
|
||||||
|
FieldType.DROPDOWN,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
() => field.customText,
|
||||||
|
)
|
||||||
|
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
|
||||||
|
.with({ type: FieldType.DATE }, () =>
|
||||||
|
convertToLocalSystemFormat(
|
||||||
|
field.customText,
|
||||||
|
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
|
||||||
|
.exhaustive()}
|
||||||
|
|
||||||
|
{field.recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||||
|
<p
|
||||||
|
className={cn('text-muted-foreground text-lg duration-200', {
|
||||||
|
'font-signature sm:text-xl md:text-2xl':
|
||||||
|
field.type === FieldType.SIGNATURE ||
|
||||||
|
field.type === FieldType.FREE_SIGNATURE,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FieldRootContainer>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ElementVisible>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
|
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
@@ -36,12 +36,6 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
|||||||
icon: File,
|
icon: File,
|
||||||
color: 'text-yellow-500 dark:text-yellow-200',
|
color: 'text-yellow-500 dark:text-yellow-200',
|
||||||
},
|
},
|
||||||
REJECTED: {
|
|
||||||
label: msg`Rejected`,
|
|
||||||
labelExtended: msg`Document rejected`,
|
|
||||||
icon: XCircle,
|
|
||||||
color: 'text-red-500 dark:text-red-300',
|
|
||||||
},
|
|
||||||
INBOX: {
|
INBOX: {
|
||||||
label: msg`Inbox`,
|
label: msg`Inbox`,
|
||||||
labelExtended: msg`Document inbox`,
|
labelExtended: msg`Document inbox`,
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import { useCallback, useEffect } from 'react';
|
|||||||
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
/**
|
|
||||||
* Not really used anymore, this causes random 500s when the user refreshes while this occurs.
|
|
||||||
*/
|
|
||||||
export const RefreshOnFocus = () => {
|
export const RefreshOnFocus = () => {
|
||||||
const { revalidate, state } = useRevalidator();
|
const { revalidate, state } = useRevalidator();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
|
||||||
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
@@ -125,8 +124,6 @@ export const TemplateEditForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||||
const { signatureTypes } = data.meta;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateTemplateSettings({
|
await updateTemplateSettings({
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
@@ -139,9 +136,6 @@ export const TemplateEditForm = ({
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
...data.meta,
|
...data.meta,
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
|
||||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -167,7 +161,6 @@ export const TemplateEditForm = ({
|
|||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
meta: {
|
meta: {
|
||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
allowDictateNextSigner: data.allowDictateNextSigner,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -194,6 +187,13 @@ export const TemplateEditForm = ({
|
|||||||
fields: data.fields,
|
fields: data.fields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await updateTemplateSettings({
|
||||||
|
templateId: template.id,
|
||||||
|
meta: {
|
||||||
|
typedSignatureEnabled: data.typedSignatureEnabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Clear all field data from localStorage
|
// Clear all field data from localStorage
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
@@ -271,7 +271,6 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
signingOrder={template.templateMeta?.signingOrder}
|
signingOrder={template.templateMeta?.signingOrder}
|
||||||
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
|
||||||
templateDirectLink={template.directLink}
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
isEnterprise={isEnterprise}
|
isEnterprise={isEnterprise}
|
||||||
@@ -285,6 +284,7 @@ export const TemplateEditForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
teamId={team?.id}
|
teamId={team?.id}
|
||||||
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -38,7 +37,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = isDocumentCompleted(row.status);
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@@ -67,7 +66,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
// const isRecipient = !!recipient;
|
// const isRecipient = !!recipient;
|
||||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = isDocumentCompleted(row.status);
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
@@ -77,7 +77,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
|
|||||||
{
|
{
|
||||||
header: _(msg`Actions`),
|
header: _(msg`Actions`),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DocumentsTableActionButton row={row.original} />
|
<DocumentsTableActionButton row={row.original} />
|
||||||
<DocumentsTableActionDropdown row={row.original} />
|
<DocumentsTableActionDropdown row={row.original} />
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
|||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
import stylesheet from './app.css?url';
|
import stylesheet from './app.css?url';
|
||||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||||
|
import { RefreshOnFocus } from './components/general/refresh-on-focus';
|
||||||
import { langCookie } from './storage/lang-cookie.server';
|
import { langCookie } from './storage/lang-cookie.server';
|
||||||
import { themeSessionResolver } from './storage/theme-session.server';
|
import { themeSessionResolver } from './storage/theme-session.server';
|
||||||
import { appMetaTags } from './utils/meta';
|
import { appMetaTags } from './utils/meta';
|
||||||
@@ -158,6 +159,8 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
|
|
||||||
|
<RefreshOnFocus />
|
||||||
|
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
|
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
|
||||||
|
|||||||
@@ -103,9 +103,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
loading={isResealDocumentLoading}
|
loading={isResealDocumentLoading}
|
||||||
disabled={document.recipients.some(
|
disabled={document.recipients.some(
|
||||||
(recipient) =>
|
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
||||||
recipient.signingStatus !== SigningStatus.SIGNED &&
|
|
||||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
|
||||||
)}
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
|
|||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@@ -25,6 +24,7 @@ import { DocumentPageViewDropdown } from '~/components/general/document/document
|
|||||||
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
|
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
|
||||||
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
|
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
|
||||||
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
|
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||||
import {
|
import {
|
||||||
DocumentStatus as DocumentStatusComponent,
|
DocumentStatus as DocumentStatusComponent,
|
||||||
@@ -200,14 +200,8 @@ export default function DocumentPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
{document.status === DocumentStatus.PENDING && (
|
||||||
<DocumentReadOnlyFields
|
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
|
||||||
fields={fields}
|
|
||||||
documentMeta={documentMeta || undefined}
|
|
||||||
showRecipientTooltip={true}
|
|
||||||
showRecipientColors={true}
|
|
||||||
recipientIds={recipients.map((recipient) => recipient.id)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
@@ -226,9 +220,6 @@ export default function DocumentPage() {
|
|||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<Trans>This document has been signed by all recipients</Trans>
|
<Trans>This document has been signed by all recipients</Trans>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.REJECTED, () => (
|
|
||||||
<Trans>This document has been rejected by a recipient</Trans>
|
|
||||||
))
|
|
||||||
.with(DocumentStatus.DRAFT, () => (
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@@ -9,7 +9,6 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
|
|||||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
@@ -72,7 +71,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === InternalDocumentStatus.COMPLETED) {
|
||||||
throw redirect(`${documentRootPath}/${documentId}`);
|
throw redirect(`${documentRootPath}/${documentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
<Trans>Document</Trans>
|
<Trans>Document</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col justify-between truncate sm:flex-row">
|
||||||
<div>
|
<div>
|
||||||
<h1
|
<h1
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
@@ -137,8 +137,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
>
|
>
|
||||||
{document.title}
|
{document.title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-col justify-between sm:flex-row">
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<DocumentStatusComponent
|
<DocumentStatusComponent
|
||||||
inheritColor
|
inheritColor
|
||||||
@@ -146,6 +145,8 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
<DocumentCertificateDownloadButton
|
<DocumentCertificateDownloadButton
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
@@ -156,14 +157,13 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
<DocumentAuditLogDownloadButton documentId={document.id} />
|
<DocumentAuditLogDownloadButton documentId={document.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||||
{documentInformation.map((info, i) => (
|
{documentInformation.map((info, i) => (
|
||||||
<div className="text-foreground text-sm" key={i}>
|
<div className="text-foreground text-sm" key={i}>
|
||||||
<h3 className="font-semibold">{_(info.description)}</h3>
|
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||||
<p className="text-muted-foreground truncate">{info.value}</p>
|
<p className="text-muted-foreground">{info.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export default function DocumentsPage() {
|
|||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
|
||||||
[ExtendedDocumentStatus.INBOX]: 0,
|
[ExtendedDocumentStatus.INBOX]: 0,
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
@@ -15,6 +14,7 @@ import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
|||||||
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
||||||
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
|
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
|
||||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
||||||
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
||||||
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
|
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
|
||||||
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
|
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
|
||||||
@@ -151,9 +151,6 @@ export default function TemplatePage() {
|
|||||||
<DocumentReadOnlyFields
|
<DocumentReadOnlyFields
|
||||||
fields={readOnlyFields}
|
fields={readOnlyFields}
|
||||||
showFieldStatus={false}
|
showFieldStatus={false}
|
||||||
showRecipientTooltip={true}
|
|
||||||
showRecipientColors={true}
|
|
||||||
recipientIds={recipients.map((recipient) => recipient.id)}
|
|
||||||
documentMeta={mockedDocumentMeta}
|
documentMeta={mockedDocumentMeta}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { FieldType, SigningStatus } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { redirect } from 'react-router';
|
import { redirect } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
|
||||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
RECIPIENT_ROLES_DESCRIPTION,
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
@@ -60,8 +59,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
throw redirect('/');
|
throw redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPlatformDocument = await isDocumentPlatform(document);
|
|
||||||
|
|
||||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||||
|
|
||||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||||
@@ -73,7 +70,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
documentLanguage,
|
documentLanguage,
|
||||||
isPlatformDocument,
|
|
||||||
auditLogs,
|
auditLogs,
|
||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
@@ -89,7 +85,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
* Update: Maybe <Trans> tags work now after RR7 migration.
|
* Update: Maybe <Trans> tags work now after RR7 migration.
|
||||||
*/
|
*/
|
||||||
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
|
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
|
||||||
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
|
const { document, documentLanguage, auditLogs, messages } = loaderData;
|
||||||
|
|
||||||
const { i18n, _ } = useLingui();
|
const { i18n, _ } = useLingui();
|
||||||
|
|
||||||
@@ -163,13 +159,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
|
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
|
||||||
log.data.recipientId === recipientId,
|
log.data.recipientId === recipientId,
|
||||||
),
|
),
|
||||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs[
|
|
||||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED
|
|
||||||
].filter(
|
|
||||||
(log) =>
|
|
||||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED &&
|
|
||||||
log.data.recipientId === recipientId,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -293,38 +282,21 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
|
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
|
||||||
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
|
|
||||||
<span className="inline-block">
|
|
||||||
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
|
|
||||||
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
|
|
||||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
|
||||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
|
||||||
: _(msg`Unknown`)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
||||||
<span className="inline-block">
|
<span className="inline-block">
|
||||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
||||||
? DateTime.fromJSDate(
|
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
|
||||||
logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt,
|
|
||||||
)
|
|
||||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||||
: _(msg`Unknown`)}
|
: _(msg`Unknown`)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
|
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
|
||||||
<span className="inline-block">
|
<span className="inline-block">
|
||||||
{recipient.signingStatus === SigningStatus.REJECTED
|
{_(
|
||||||
? recipient.rejectionReason
|
|
||||||
: _(
|
|
||||||
isOwner(recipient.email)
|
isOwner(recipient.email)
|
||||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||||
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
||||||
@@ -341,7 +313,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isPlatformDocument && (
|
|
||||||
<div className="my-8 flex-row-reverse">
|
<div className="my-8 flex-row-reverse">
|
||||||
<div className="flex items-end justify-end gap-x-4">
|
<div className="flex items-end justify-end gap-x-4">
|
||||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||||
@@ -351,7 +322,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
<BrandingLogo className="max-h-6 print:max-h-4" />
|
<BrandingLogo className="max-h-6 print:max-h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { PlusIcon } from 'lucide-react';
|
import { PlusIcon } from 'lucide-react';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { Link, Outlet, isRouteErrorResponse } from 'react-router';
|
import { Link, Outlet } from 'react-router';
|
||||||
|
|
||||||
import LogoIcon from '@documenso/assets/logo_icon.png';
|
import LogoIcon from '@documenso/assets/logo_icon.png';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@@ -16,8 +16,6 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
|||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags('Profile');
|
return appMetaTags('Profile');
|
||||||
}
|
}
|
||||||
@@ -98,9 +96,7 @@ export default function PublicProfileLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary() {
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
|
||||||
|
|
||||||
const errorCodeMap = {
|
const errorCodeMap = {
|
||||||
404: {
|
404: {
|
||||||
subHeading: msg`404 Profile not found`,
|
subHeading: msg`404 Profile not found`,
|
||||||
@@ -111,7 +107,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericErrorLayout
|
<GenericErrorLayout
|
||||||
errorCode={errorCode}
|
|
||||||
errorCodeMap={errorCodeMap}
|
errorCodeMap={errorCodeMap}
|
||||||
secondaryButton={null}
|
secondaryButton={null}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { Link, Outlet, isRouteErrorResponse } from 'react-router';
|
import { Link, Outlet } from 'react-router';
|
||||||
|
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -8,8 +8,6 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A layout to handle scenarios where the user is a recipient of a given resource
|
* A layout to handle scenarios where the user is a recipient of a given resource
|
||||||
* where we do not care whether they are authenticated or not.
|
* where we do not care whether they are authenticated or not.
|
||||||
@@ -32,12 +30,9 @@ export default function RecipientLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary() {
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericErrorLayout
|
<GenericErrorLayout
|
||||||
errorCode={errorCode}
|
|
||||||
secondaryButton={null}
|
secondaryButton={null}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
<Button asChild className="w-32">
|
<Button asChild className="w-32">
|
||||||
|
|||||||
@@ -79,14 +79,7 @@ export default function DirectTemplatePage() {
|
|||||||
const { template, directTemplateRecipient } = data;
|
const { template, directTemplateRecipient } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningProvider
|
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
||||||
email={user?.email}
|
|
||||||
fullName={user?.name}
|
|
||||||
signature={user?.signature}
|
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
|
||||||
>
|
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={directTemplateRecipient}
|
recipient={directTemplateRecipient}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { Clock8 } from 'lucide-react';
|
import { Clock8 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
||||||
@@ -13,7 +13,6 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
|
|||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
|
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
@@ -73,24 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
? await getRecipientsForAssistant({
|
? await getRecipientsForAssistant({
|
||||||
token,
|
token,
|
||||||
})
|
})
|
||||||
: [recipient];
|
: [];
|
||||||
|
|
||||||
if (
|
|
||||||
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
|
|
||||||
recipient.role !== RecipientRole.ASSISTANT
|
|
||||||
) {
|
|
||||||
const nextPendingRecipient = await getNextPendingRecipient({
|
|
||||||
documentId: document.id,
|
|
||||||
currentRecipientId: recipient.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextPendingRecipient) {
|
|
||||||
allRecipients.push({
|
|
||||||
...nextPendingRecipient,
|
|
||||||
fields: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: document.authOptions,
|
documentAuth: document.authOptions,
|
||||||
@@ -178,7 +160,7 @@ export default function SigningPage() {
|
|||||||
recipientWithFields,
|
recipientWithFields,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
|
if (document.deletedAt) {
|
||||||
return (
|
return (
|
||||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
||||||
<SigningCard3D
|
<SigningCard3D
|
||||||
@@ -233,9 +215,6 @@ export default function SigningPage() {
|
|||||||
email={recipient.email}
|
email={recipient.email}
|
||||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={document.authOptions}
|
documentAuthOptions={document.authOptions}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
|||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
@@ -206,12 +205,12 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
{isDocumentCompleted(document.status) ? (
|
{document.status === DocumentStatus.COMPLETED ? (
|
||||||
<DocumentDownloadButton
|
<DocumentDownloadButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
fileName={document.title}
|
fileName={document.title}
|
||||||
documentData={document.documentData}
|
documentData={document.documentData}
|
||||||
disabled={!isDocumentCompleted(document.status)}
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DocumentDialog
|
<DocumentDialog
|
||||||
@@ -269,7 +268,7 @@ export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentComple
|
|||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,14 +131,7 @@ export default function EmbedDirectTemplatePage() {
|
|||||||
} = useSuperLoaderData<typeof loader>();
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningProvider
|
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
||||||
email={user?.email}
|
|
||||||
fullName={user?.name}
|
|
||||||
signature={user?.signature}
|
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
|
||||||
>
|
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
||||||
import { data } from 'react-router';
|
import { data } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
|
|||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
|
||||||
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
||||||
@@ -156,9 +155,6 @@ export default function EmbedSignDocumentPage() {
|
|||||||
email={recipient.email}
|
email={recipient.email}
|
||||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={document.authOptions}
|
documentAuthOptions={document.authOptions}
|
||||||
@@ -172,7 +168,7 @@ export default function EmbedSignDocumentPage() {
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
metadata={document.documentMeta}
|
metadata={document.documentMeta}
|
||||||
isCompleted={isDocumentCompleted(document.status)}
|
isCompleted={document.status === DocumentStatus.COMPLETED}
|
||||||
hidePoweredBy={
|
hidePoweredBy={
|
||||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const themeSessionStorage = createCookieSessionStorage({
|
|||||||
secrets: ['insecure-secret-do-not-care'],
|
secrets: ['insecure-secret-do-not-care'],
|
||||||
secure: useSecureCookies,
|
secure: useSecureCookies,
|
||||||
domain: getCookieDomain(),
|
domain: getCookieDomain(),
|
||||||
maxAge: 60 * 60 * 24 * 365,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,5 @@
|
|||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
}
|
||||||
"version": "1.10.0-rc.1"
|
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ command -v docker >/dev/null 2>&1 || {
|
|||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
# Get Git information
|
|
||||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
GIT_SHA="$(git rev-parse HEAD)"
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
@@ -16,39 +15,12 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
|
|||||||
echo "App version: $APP_VERSION"
|
echo "App version: $APP_VERSION"
|
||||||
echo "Git SHA: $GIT_SHA"
|
echo "Git SHA: $GIT_SHA"
|
||||||
|
|
||||||
# Build with temporary base tag
|
|
||||||
docker build -f "$SCRIPT_DIR/Dockerfile" \
|
docker build -f "$SCRIPT_DIR/Dockerfile" \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
-t "documenso-base" \
|
-t "documenso/documenso:latest" \
|
||||||
|
-t "documenso/documenso:$GIT_SHA" \
|
||||||
|
-t "documenso/documenso:$APP_VERSION" \
|
||||||
|
-t "ghcr.io/documenso/documenso:latest" \
|
||||||
|
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
|
||||||
|
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
|
||||||
"$MONOREPO_ROOT"
|
"$MONOREPO_ROOT"
|
||||||
|
|
||||||
# Handle repository tagging
|
|
||||||
if [ ! -z "$DOCKER_REPOSITORY" ]; then
|
|
||||||
echo "Using custom repository: $DOCKER_REPOSITORY"
|
|
||||||
|
|
||||||
# Add tags for custom repository
|
|
||||||
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
|
|
||||||
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
|
|
||||||
|
|
||||||
# Add version tag if available
|
|
||||||
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
|
|
||||||
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Using default repositories: dockerhub and ghcr.io"
|
|
||||||
|
|
||||||
# Add tags for both default repositories
|
|
||||||
docker tag "documenso-base" "documenso/documenso:latest"
|
|
||||||
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
|
|
||||||
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
|
|
||||||
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
|
|
||||||
|
|
||||||
# Add version tags if available
|
|
||||||
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
|
|
||||||
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
|
|
||||||
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove the temporary base tag
|
|
||||||
docker rmi "documenso-base"
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
|||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
# Get the platform from environment variable or set to linux/amd64 if not set
|
# Get the platform from environment variable or set to linux/amd64 if not set
|
||||||
|
# quote the string to prevent word splitting
|
||||||
if [ -z "$PLATFORM" ]; then
|
if [ -z "$PLATFORM" ]; then
|
||||||
PLATFORM="linux/amd64"
|
PLATFORM="linux/amd64"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get Git information
|
|
||||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
GIT_SHA="$(git rev-parse HEAD)"
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
@@ -21,41 +21,14 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
|
|||||||
echo "App version: $APP_VERSION"
|
echo "App version: $APP_VERSION"
|
||||||
echo "Git SHA: $GIT_SHA"
|
echo "Git SHA: $GIT_SHA"
|
||||||
|
|
||||||
# Build with temporary base tag
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
-f "$SCRIPT_DIR/Dockerfile" \
|
-f "$SCRIPT_DIR/Dockerfile" \
|
||||||
--platform=$PLATFORM \
|
--platform=$PLATFORM \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
-t "documenso-base" \
|
-t "documenso/documenso:latest" \
|
||||||
|
-t "documenso/documenso:$GIT_SHA" \
|
||||||
|
-t "documenso/documenso:$APP_VERSION" \
|
||||||
|
-t "ghcr.io/documenso/documenso:latest" \
|
||||||
|
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
|
||||||
|
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
|
||||||
"$MONOREPO_ROOT"
|
"$MONOREPO_ROOT"
|
||||||
|
|
||||||
# Handle repository tagging
|
|
||||||
if [ ! -z "$DOCKER_REPOSITORY" ]; then
|
|
||||||
echo "Using custom repository: $DOCKER_REPOSITORY"
|
|
||||||
|
|
||||||
# Add tags for custom repository
|
|
||||||
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
|
|
||||||
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
|
|
||||||
|
|
||||||
# Add version tag if available
|
|
||||||
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
|
|
||||||
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Using default repositories: dockerhub and ghcr.io"
|
|
||||||
|
|
||||||
# Add tags for both default repositories
|
|
||||||
docker tag "documenso-base" "documenso/documenso:latest"
|
|
||||||
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
|
|
||||||
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
|
|
||||||
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
|
|
||||||
|
|
||||||
# Add version tags if available
|
|
||||||
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
|
|
||||||
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
|
|
||||||
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove the temporary base tag
|
|
||||||
docker rmi "documenso-base"
|
|
||||||
|
|||||||
5
package-lock.json
generated
5
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.10.0-rc.1",
|
"version": "1.9.0-rc.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.10.0-rc.1",
|
"version": "1.9.0-rc.11",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -95,7 +95,6 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "1.10.0-rc.1",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.0-rc.1",
|
"version": "1.9.0-rc.11",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"prepare": "husky && husky install || true",
|
"prepare": "husky && husky install || true",
|
||||||
"commitlint": "commitlint --edit",
|
"commitlint": "commitlint --edit",
|
||||||
"clean": "turbo run clean && rimraf node_modules",
|
"clean": "turbo run clean && rimraf node_modules",
|
||||||
"d": "npm run dx && npm run translate:compile && npm run dev",
|
"d": "npm run dx && npm run dev",
|
||||||
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
|
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
|
||||||
"dx:up": "docker compose -f docker/development/compose.yml up -d",
|
"dx:up": "docker compose -f docker/development/compose.yml up -d",
|
||||||
"dx:down": "docker compose -f docker/development/compose.yml down",
|
"dx:down": "docker compose -f docker/development/compose.yml down",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { DocumentDataType, SigningStatus, TeamMemberRole } from '@prisma/client';
|
import { DocumentDataType, DocumentStatus, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||||
import { tsr } from '@ts-rest/serverless/fetch';
|
import { tsr } from '@ts-rest/serverless/fetch';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -50,7 +50,6 @@ import {
|
|||||||
getPresignGetUrl,
|
getPresignGetUrl,
|
||||||
getPresignPostUrl,
|
getPresignPostUrl,
|
||||||
} from '@documenso/lib/universal/upload/server-actions';
|
} from '@documenso/lib/universal/upload/server-actions';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@@ -177,7 +176,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDocumentCompleted(document.status)) {
|
if (document.status !== DocumentStatus.COMPLETED) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -323,11 +322,8 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
dateFormat: dateFormat?.value,
|
dateFormat: dateFormat?.value,
|
||||||
redirectUrl: body.meta.redirectUrl,
|
redirectUrl: body.meta.redirectUrl,
|
||||||
signingOrder: body.meta.signingOrder,
|
signingOrder: body.meta.signingOrder,
|
||||||
allowDictateNextSigner: body.meta.allowDictateNextSigner,
|
|
||||||
language: body.meta.language,
|
language: body.meta.language,
|
||||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
|
|
||||||
drawSignatureEnabled: body.meta.drawSignatureEnabled,
|
|
||||||
distributionMethod: body.meta.distributionMethod,
|
distributionMethod: body.meta.distributionMethod,
|
||||||
emailSettings: body.meta.emailSettings,
|
emailSettings: body.meta.emailSettings,
|
||||||
requestMetadata: metadata,
|
requestMetadata: metadata,
|
||||||
@@ -584,7 +580,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
recipients: body.recipients,
|
recipients: body.recipients,
|
||||||
prefillFields: body.prefillFields,
|
|
||||||
override: {
|
override: {
|
||||||
title: body.title,
|
title: body.title,
|
||||||
...body.meta,
|
...body.meta,
|
||||||
@@ -673,7 +668,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -776,7 +771,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -867,7 +862,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -926,7 +921,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -991,7 +986,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: { message: 'Document is already completed' },
|
body: { message: 'Document is already completed' },
|
||||||
@@ -1153,7 +1148,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -1241,7 +1236,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
|
||||||
extendZodWithOpenApi(z);
|
extendZodWithOpenApi(z);
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export const ZSendDocumentForSigningMutationSchema = z
|
|||||||
'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
|
'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
|
.or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
|
||||||
|
|
||||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||||
|
|
||||||
@@ -155,11 +155,8 @@ export const ZCreateDocumentMutationSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
redirectUrl: z.string(),
|
redirectUrl: z.string(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
allowDictateNextSigner: z.boolean().optional(),
|
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||||
uploadSignatureEnabled: z.boolean().optional().default(true),
|
|
||||||
drawSignatureEnabled: z.boolean().optional().default(true),
|
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
})
|
})
|
||||||
@@ -221,7 +218,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
dateFormat: z.string(),
|
dateFormat: z.string(),
|
||||||
redirectUrl: z.string(),
|
redirectUrl: z.string(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
allowDictateNextSigner: z.boolean().optional(),
|
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
@@ -289,12 +285,9 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
dateFormat: z.string(),
|
dateFormat: z.string(),
|
||||||
redirectUrl: ZUrlSchema,
|
redirectUrl: ZUrlSchema,
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||||
allowDictateNextSigner: z.boolean(),
|
|
||||||
language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
uploadSignatureEnabled: z.boolean(),
|
|
||||||
drawSignatureEnabled: z.boolean(),
|
|
||||||
emailSettings: ZDocumentEmailSettingsSchema,
|
emailSettings: ZDocumentEmailSettingsSchema,
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
@@ -306,7 +299,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||||
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
|
|||||||
@@ -1,614 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
|
||||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
|
||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../../fixtures/authentication';
|
|
||||||
|
|
||||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|
||||||
|
|
||||||
test.describe('Template Field Prefill API v1', () => {
|
|
||||||
test('should create a document from template with prefilled fields', async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template with seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template with Advanced Fields',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add fields to the template
|
|
||||||
// Add TEXT field
|
|
||||||
const textField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.TEXT,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 5,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Text Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add NUMBER field
|
|
||||||
const numberField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 15,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'number',
|
|
||||||
label: 'Number Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add RADIO field
|
|
||||||
const radioField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 25,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Radio Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Option A', checked: false },
|
|
||||||
{ id: 2, value: 'Option B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add CHECKBOX field
|
|
||||||
const checkboxField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.CHECKBOX,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 35,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Checkbox Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Check A', checked: false },
|
|
||||||
{ id: 2, value: 'Check B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add DROPDOWN field
|
|
||||||
const dropdownField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.DROPDOWN,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 45,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Dropdown Field',
|
|
||||||
values: [{ value: 'Select A' }, { value: 'Select B' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Sign in as the user
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Navigate to the template
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
|
||||||
|
|
||||||
// 8. Create a document from the template with prefilled fields
|
|
||||||
const response = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
title: 'Document with Prefilled Fields',
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: 'SIGNER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prefillFields: [
|
|
||||||
{
|
|
||||||
id: textField.id,
|
|
||||||
type: 'text',
|
|
||||||
label: 'Prefilled Text',
|
|
||||||
value: 'This is prefilled text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: numberField.id,
|
|
||||||
type: 'number',
|
|
||||||
label: 'Prefilled Number',
|
|
||||||
value: '42',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: radioField.id,
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Prefilled Radio',
|
|
||||||
value: 'Option A',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: checkboxField.id,
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Prefilled Checkbox',
|
|
||||||
value: ['Check A', 'Check B'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: dropdownField.id,
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Prefilled Dropdown',
|
|
||||||
value: 'Select B',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(responseData.documentId).toBeDefined();
|
|
||||||
|
|
||||||
// 9. Verify the document was created with prefilled fields
|
|
||||||
const document = await prisma.document.findUnique({
|
|
||||||
where: {
|
|
||||||
id: responseData.documentId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
fields: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document).not.toBeNull();
|
|
||||||
|
|
||||||
// 10. Verify each field has the correct prefilled values
|
|
||||||
const documentTextField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
|
|
||||||
);
|
|
||||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Prefilled Text',
|
|
||||||
text: 'This is prefilled text',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentNumberField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
|
|
||||||
);
|
|
||||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'number',
|
|
||||||
label: 'Prefilled Number',
|
|
||||||
value: '42',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentRadioField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
|
|
||||||
);
|
|
||||||
expect(documentRadioField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Prefilled Radio',
|
|
||||||
});
|
|
||||||
// Check that the correct radio option is selected
|
|
||||||
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
|
|
||||||
const selectedRadioOption = radioValues.find((option) => option.checked);
|
|
||||||
expect(selectedRadioOption?.value).toBe('Option A');
|
|
||||||
|
|
||||||
const documentCheckboxField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
|
|
||||||
);
|
|
||||||
expect(documentCheckboxField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Prefilled Checkbox',
|
|
||||||
});
|
|
||||||
// Check that the correct checkbox options are selected
|
|
||||||
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
|
|
||||||
const checkedOptions = checkboxValues.filter((option) => option.checked);
|
|
||||||
expect(checkedOptions.length).toBe(2);
|
|
||||||
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
|
|
||||||
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
|
|
||||||
|
|
||||||
const documentDropdownField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
|
|
||||||
);
|
|
||||||
expect(documentDropdownField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Prefilled Dropdown',
|
|
||||||
defaultValue: 'Select B',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 11. Sign in as the recipient and verify the prefilled fields are visible
|
|
||||||
const documentRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
documentId: document?.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send the document to the recipient
|
|
||||||
const sendResponse = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
sendEmail: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sendResponse.ok()).toBeTruthy();
|
|
||||||
expect(sendResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(documentRecipient).not.toBeNull();
|
|
||||||
|
|
||||||
// Visit the signing page
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
|
||||||
|
|
||||||
// Verify the prefilled fields are visible with correct values
|
|
||||||
// Text field
|
|
||||||
await expect(page.getByText('This is prefilled')).toBeVisible();
|
|
||||||
|
|
||||||
// Number field
|
|
||||||
await expect(page.getByText('42')).toBeVisible();
|
|
||||||
|
|
||||||
// Radio field
|
|
||||||
await expect(page.getByText('Option A')).toBeVisible();
|
|
||||||
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
|
|
||||||
|
|
||||||
// Checkbox field
|
|
||||||
await expect(page.getByText('Check A')).toBeVisible();
|
|
||||||
await expect(page.getByText('Check B')).toBeVisible();
|
|
||||||
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
|
|
||||||
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
|
|
||||||
|
|
||||||
// Dropdown field
|
|
||||||
await expect(page.getByText('Select B')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create a document from template without prefilled fields', async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template with seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template with Default Fields',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add fields to the template
|
|
||||||
// Add TEXT field
|
|
||||||
const textField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.TEXT,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 5,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Default Text Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add NUMBER field
|
|
||||||
const numberField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 15,
|
|
||||||
width: 10,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'number',
|
|
||||||
label: 'Default Number Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Sign in as the user
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Navigate to the template
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
|
||||||
|
|
||||||
// 8. Create a document from the template without prefilled fields
|
|
||||||
const response = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
title: 'Document with Default Fields',
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: 'SIGNER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(responseData.documentId).toBeDefined();
|
|
||||||
|
|
||||||
// 9. Verify the document was created with default fields
|
|
||||||
const document = await prisma.document.findUnique({
|
|
||||||
where: {
|
|
||||||
id: responseData.documentId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
fields: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document).not.toBeNull();
|
|
||||||
|
|
||||||
// 10. Verify fields have their default values
|
|
||||||
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
|
|
||||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Default Text Field',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
|
|
||||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'number',
|
|
||||||
label: 'Default Number Field',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 11. Sign in as the recipient and verify the default fields are visible
|
|
||||||
const documentRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
documentId: document?.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(documentRecipient).not.toBeNull();
|
|
||||||
|
|
||||||
const sendResponse = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
sendEmail: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sendResponse.ok()).toBeTruthy();
|
|
||||||
expect(sendResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
// Visit the signing page
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
|
||||||
|
|
||||||
// Verify the default fields are visible with correct labels
|
|
||||||
await expect(page.getByText('Default Text Field')).toBeVisible();
|
|
||||||
await expect(page.getByText('Default Number Field')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle invalid field prefill values', async ({ request }) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template using seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template for Invalid Test',
|
|
||||||
visibility: 'EVERYONE',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add a field to the template
|
|
||||||
const field = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
page: 1,
|
|
||||||
positionX: 100,
|
|
||||||
positionY: 100,
|
|
||||||
width: 100,
|
|
||||||
height: 50,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Radio Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Option A', checked: false },
|
|
||||||
{ id: 2, value: 'Option B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Try to create a document with invalid prefill value
|
|
||||||
const response = await request.post(
|
|
||||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
title: 'Document with Invalid Prefill',
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: 'SIGNER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prefillFields: [
|
|
||||||
{
|
|
||||||
id: field.id,
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Invalid Radio',
|
|
||||||
value: 'Non-existent Option', // This option doesn't exist
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 7. Verify the request fails with appropriate error
|
|
||||||
expect(response.ok()).toBeFalsy();
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
|
|
||||||
const errorData = await response.json();
|
|
||||||
expect(errorData.message).toContain('not found in options for RADIO field');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
|
||||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
|
||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../../fixtures/authentication';
|
|
||||||
|
|
||||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|
||||||
|
|
||||||
test.describe('Template Field Prefill API v2', () => {
|
|
||||||
test('should create a document from template with prefilled fields', async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template with seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template with Advanced Fields V2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add fields to the template
|
|
||||||
// Add TEXT field
|
|
||||||
const textField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.TEXT,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 5,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Text Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add NUMBER field
|
|
||||||
const numberField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 15,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'number',
|
|
||||||
label: 'Number Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add RADIO field
|
|
||||||
const radioField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 25,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Radio Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Option A', checked: false },
|
|
||||||
{ id: 2, value: 'Option B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add CHECKBOX field
|
|
||||||
const checkboxField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.CHECKBOX,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 35,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Checkbox Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Check A', checked: false },
|
|
||||||
{ id: 2, value: 'Check B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add DROPDOWN field
|
|
||||||
const dropdownField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.DROPDOWN,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 45,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Dropdown Field',
|
|
||||||
values: [{ value: 'Select A' }, { value: 'Select B' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Sign in as the user
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Navigate to the template
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
|
||||||
|
|
||||||
// 8. Create a document from the template with prefilled fields using v2 API
|
|
||||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prefillFields: [
|
|
||||||
{
|
|
||||||
id: textField.id,
|
|
||||||
type: 'text',
|
|
||||||
label: 'Prefilled Text',
|
|
||||||
value: 'This is prefilled text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: numberField.id,
|
|
||||||
type: 'number',
|
|
||||||
label: 'Prefilled Number',
|
|
||||||
value: '42',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: radioField.id,
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Prefilled Radio',
|
|
||||||
value: 'Option A',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: checkboxField.id,
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Prefilled Checkbox',
|
|
||||||
value: ['Check A', 'Check B'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: dropdownField.id,
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Prefilled Dropdown',
|
|
||||||
value: 'Select B',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(responseData.id).toBeDefined();
|
|
||||||
|
|
||||||
// 9. Verify the document was created with prefilled fields
|
|
||||||
const document = await prisma.document.findUnique({
|
|
||||||
where: {
|
|
||||||
id: responseData.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
fields: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document).not.toBeNull();
|
|
||||||
|
|
||||||
// 10. Verify each field has the correct prefilled values
|
|
||||||
const documentTextField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
|
|
||||||
);
|
|
||||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Prefilled Text',
|
|
||||||
text: 'This is prefilled text',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentNumberField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
|
|
||||||
);
|
|
||||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'number',
|
|
||||||
label: 'Prefilled Number',
|
|
||||||
value: '42',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentRadioField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
|
|
||||||
);
|
|
||||||
expect(documentRadioField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Prefilled Radio',
|
|
||||||
});
|
|
||||||
// Check that the correct radio option is selected
|
|
||||||
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
|
|
||||||
const selectedRadioOption = radioValues.find((option) => option.checked);
|
|
||||||
expect(selectedRadioOption?.value).toBe('Option A');
|
|
||||||
|
|
||||||
const documentCheckboxField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
|
|
||||||
);
|
|
||||||
expect(documentCheckboxField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Prefilled Checkbox',
|
|
||||||
});
|
|
||||||
// Check that the correct checkbox options are selected
|
|
||||||
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
|
|
||||||
const checkedOptions = checkboxValues.filter((option) => option.checked);
|
|
||||||
expect(checkedOptions.length).toBe(2);
|
|
||||||
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
|
|
||||||
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
|
|
||||||
|
|
||||||
const documentDropdownField = document?.fields.find(
|
|
||||||
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
|
|
||||||
);
|
|
||||||
expect(documentDropdownField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'dropdown',
|
|
||||||
label: 'Prefilled Dropdown',
|
|
||||||
defaultValue: 'Select B',
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
documentId: document?.id,
|
|
||||||
meta: {
|
|
||||||
subject: 'Test Subject',
|
|
||||||
message: 'Test Message',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(sendResponse.ok()).toBeTruthy();
|
|
||||||
await expect(sendResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
// 11. Sign in as the recipient and verify the prefilled fields are visible
|
|
||||||
const documentRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
documentId: document?.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(documentRecipient).not.toBeNull();
|
|
||||||
|
|
||||||
// Visit the signing page
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
|
||||||
|
|
||||||
// Verify the prefilled fields are visible with correct values
|
|
||||||
// Text field
|
|
||||||
await expect(page.getByText('This is prefilled')).toBeVisible();
|
|
||||||
|
|
||||||
// Number field
|
|
||||||
await expect(page.getByText('42')).toBeVisible();
|
|
||||||
|
|
||||||
// Radio field
|
|
||||||
await expect(page.getByText('Option A')).toBeVisible();
|
|
||||||
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
|
|
||||||
|
|
||||||
// Checkbox field
|
|
||||||
await expect(page.getByText('Check A')).toBeVisible();
|
|
||||||
await expect(page.getByText('Check B')).toBeVisible();
|
|
||||||
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
|
|
||||||
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
|
|
||||||
|
|
||||||
// Dropdown field
|
|
||||||
await expect(page.getByText('Select B')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create a document from template without prefilled fields', async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template with seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template with Default Fields V2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add fields to the template
|
|
||||||
// Add TEXT field
|
|
||||||
const textField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.TEXT,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 5,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Default Text Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add NUMBER field
|
|
||||||
const numberField = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
page: 1,
|
|
||||||
positionX: 5,
|
|
||||||
positionY: 15,
|
|
||||||
width: 20,
|
|
||||||
height: 5,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'number',
|
|
||||||
label: 'Default Number Field',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Sign in as the user
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Navigate to the template
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
|
||||||
|
|
||||||
// 8. Create a document from the template without prefilled fields using v2 API
|
|
||||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
|
|
||||||
expect(responseData.id).toBeDefined();
|
|
||||||
|
|
||||||
// 9. Verify the document was created with default fields
|
|
||||||
const document = await prisma.document.findUnique({
|
|
||||||
where: {
|
|
||||||
id: responseData.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
fields: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document).not.toBeNull();
|
|
||||||
|
|
||||||
// 10. Verify fields have their default values
|
|
||||||
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
|
|
||||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'text',
|
|
||||||
label: 'Default Text Field',
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
|
|
||||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
|
||||||
type: 'number',
|
|
||||||
label: 'Default Number Field',
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
documentId: document?.id,
|
|
||||||
meta: {
|
|
||||||
subject: 'Test Subject',
|
|
||||||
message: 'Test Message',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(sendResponse.ok()).toBeTruthy();
|
|
||||||
await expect(sendResponse.status()).toBe(200);
|
|
||||||
|
|
||||||
// 11. Sign in as the recipient and verify the default fields are visible
|
|
||||||
const documentRecipient = await prisma.recipient.findFirst({
|
|
||||||
where: {
|
|
||||||
documentId: document?.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(documentRecipient).not.toBeNull();
|
|
||||||
|
|
||||||
// Visit the signing page
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
|
||||||
|
|
||||||
await expect(page.getByText('This is prefilled')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle invalid field prefill values', async ({ request }) => {
|
|
||||||
// 1. Create a user
|
|
||||||
const user = await seedUser();
|
|
||||||
|
|
||||||
// 2. Create an API token for the user
|
|
||||||
const { token } = await createApiToken({
|
|
||||||
userId: user.id,
|
|
||||||
tokenName: 'test-token',
|
|
||||||
expiresIn: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Create a template using seedBlankTemplate
|
|
||||||
const template = await seedBlankTemplate(user, {
|
|
||||||
createTemplateOptions: {
|
|
||||||
title: 'Template for Invalid Test V2',
|
|
||||||
visibility: 'EVERYONE',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create a recipient for the template
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
token: 'test-token',
|
|
||||||
readStatus: 'NOT_OPENED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Add a field to the template
|
|
||||||
const field = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.RADIO,
|
|
||||||
page: 1,
|
|
||||||
positionX: 100,
|
|
||||||
positionY: 100,
|
|
||||||
width: 100,
|
|
||||||
height: 50,
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
fieldMeta: {
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Radio Field',
|
|
||||||
values: [
|
|
||||||
{ id: 1, value: 'Option A', checked: false },
|
|
||||||
{ id: 2, value: 'Option B', checked: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Try to create a document with invalid prefill value
|
|
||||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
templateId: template.id,
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
id: recipient.id,
|
|
||||||
email: 'recipient@example.com',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
prefillFields: [
|
|
||||||
{
|
|
||||||
id: field.id,
|
|
||||||
type: 'radio',
|
|
||||||
label: 'Invalid Radio',
|
|
||||||
value: 'Non-existent Option', // This option doesn't exist
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 8. Verify the request fails with appropriate error
|
|
||||||
expect(response.ok()).toBeFalsy();
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
|
|
||||||
const errorData = await response.json();
|
|
||||||
expect(errorData.message).toContain('not found in options for RADIO field');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -210,7 +210,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
fields: [FieldType.DATE, FieldType.SIGNATURE],
|
fields: [FieldType.DATE],
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
@@ -246,9 +246,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.some((field) => field.type === FieldType.SIGNATURE)) {
|
|
||||||
await signSignaturePad(page);
|
await signSignaturePad(page);
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
@@ -309,7 +307,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
fields: [FieldType.DATE, FieldType.SIGNATURE],
|
fields: [FieldType.DATE],
|
||||||
updateDocumentOptions: {
|
updateDocumentOptions: {
|
||||||
authOptions: createDocumentAuthOptions({
|
authOptions: createDocumentAuthOptions({
|
||||||
globalAccessAuth: null,
|
globalAccessAuth: null,
|
||||||
@@ -351,9 +349,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.some((field) => field.type === FieldType.SIGNATURE)) {
|
|
||||||
await signSignaturePad(page);
|
await signSignaturePad(page);
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|||||||
@@ -1,390 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
DocumentSigningOrder,
|
|
||||||
DocumentStatus,
|
|
||||||
FieldType,
|
|
||||||
RecipientRole,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@prisma/client';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
|
|
||||||
|
|
||||||
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const user = await seedUser();
|
|
||||||
const firstSigner = await seedUser();
|
|
||||||
const secondSigner = await seedUser();
|
|
||||||
const thirdSigner = await seedUser();
|
|
||||||
|
|
||||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
|
||||||
owner: user,
|
|
||||||
recipients: [firstSigner, secondSigner, thirdSigner],
|
|
||||||
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
|
|
||||||
updateDocumentOptions: {
|
|
||||||
documentMeta: {
|
|
||||||
upsert: {
|
|
||||||
create: {
|
|
||||||
allowDictateNextSigner: true,
|
|
||||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
allowDictateNextSigner: true,
|
|
||||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstRecipient = recipients[0];
|
|
||||||
const { token, fields } = firstRecipient;
|
|
||||||
|
|
||||||
const signUrl = `/sign/${token}`;
|
|
||||||
|
|
||||||
await page.goto(signUrl);
|
|
||||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
|
||||||
|
|
||||||
await signSignaturePad(page);
|
|
||||||
|
|
||||||
// Fill in all fields
|
|
||||||
for (const field of fields) {
|
|
||||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
|
||||||
|
|
||||||
if (field.type === FieldType.TEXT) {
|
|
||||||
await page.locator('#custom-text').fill('TEXT');
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete signing and update next recipient
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Use dialog context to ensure we're targeting the correct form fields
|
|
||||||
const dialog = page.getByRole('dialog');
|
|
||||||
await dialog.getByLabel('Name').fill('New Recipient');
|
|
||||||
await dialog.getByLabel('Email').fill('new.recipient@example.com');
|
|
||||||
|
|
||||||
// Submit and verify completion
|
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
|
||||||
await page.waitForURL(`${signUrl}/complete`);
|
|
||||||
|
|
||||||
// Verify document and recipient states
|
|
||||||
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
|
||||||
where: { id: document.id },
|
|
||||||
include: {
|
|
||||||
recipients: {
|
|
||||||
orderBy: { signingOrder: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Document should still be pending as there are more recipients
|
|
||||||
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
|
||||||
|
|
||||||
// First recipient should be completed
|
|
||||||
const updatedFirstRecipient = updatedDocument.recipients[0];
|
|
||||||
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
|
||||||
|
|
||||||
// Second recipient should be the new recipient
|
|
||||||
const updatedSecondRecipient = updatedDocument.recipients[1];
|
|
||||||
expect(updatedSecondRecipient.name).toBe('New Recipient');
|
|
||||||
expect(updatedSecondRecipient.email).toBe('new.recipient@example.com');
|
|
||||||
expect(updatedSecondRecipient.signingOrder).toBe(2);
|
|
||||||
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', async ({ page }) => {
|
|
||||||
const user = await seedUser();
|
|
||||||
const firstSigner = await seedUser();
|
|
||||||
const secondSigner = await seedUser();
|
|
||||||
|
|
||||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
|
||||||
owner: user,
|
|
||||||
recipients: [firstSigner, secondSigner],
|
|
||||||
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
|
|
||||||
updateDocumentOptions: {
|
|
||||||
documentMeta: {
|
|
||||||
upsert: {
|
|
||||||
create: {
|
|
||||||
allowDictateNextSigner: false,
|
|
||||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
allowDictateNextSigner: false,
|
|
||||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstRecipient = recipients[0];
|
|
||||||
const { token, fields } = firstRecipient;
|
|
||||||
|
|
||||||
const signUrl = `/sign/${token}`;
|
|
||||||
|
|
||||||
await page.goto(signUrl);
|
|
||||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
|
||||||
|
|
||||||
await signSignaturePad(page);
|
|
||||||
|
|
||||||
// Fill in all fields
|
|
||||||
for (const field of fields) {
|
|
||||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
|
||||||
|
|
||||||
if (field.type === FieldType.TEXT) {
|
|
||||||
await page.locator('#custom-text').fill('TEXT');
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete signing
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
|
||||||
|
|
||||||
// Verify next recipient UI is not shown
|
|
||||||
await expect(
|
|
||||||
page.getByText('The next recipient to sign this document will be'),
|
|
||||||
).not.toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
|
||||||
|
|
||||||
// Submit and verify completion
|
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
|
||||||
await page.waitForURL(`${signUrl}/complete`);
|
|
||||||
|
|
||||||
// Verify document and recipient states
|
|
||||||
|
|
||||||
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
|
||||||
where: { id: document.id },
|
|
||||||
include: {
|
|
||||||
recipients: {
|
|
||||||
orderBy: { signingOrder: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Document should still be pending as there are more recipients
|
|
||||||
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
|
||||||
|
|
||||||
// First recipient should be completed
|
|
||||||
const updatedFirstRecipient = updatedDocument.recipients[0];
|
|
||||||
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
|
|
||||||
|
|
||||||
// Second recipient should remain unchanged
|
|
||||||
const updatedSecondRecipient = updatedDocument.recipients[1];
|
|
||||||
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
|
|
||||||
expect(updatedSecondRecipient.signingOrder).toBe(2);
|
|
||||||
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async ({ page }) => {
|
|
||||||
const user = await seedUser();
|
|
||||||
const firstSigner = await seedUser();
|
|
||||||
const secondSigner = await seedUser();
|
|
||||||
|
|
||||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
|
||||||
owner: user,
|
|
||||||
recipients: [firstSigner, secondSigner],
|
|
||||||
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
|
|
||||||
updateDocumentOptions: {
|
|
||||||
documentMeta: {
|
|
||||||
upsert: {
|
|
||||||
create: {
|
|
||||||
allowDictateNextSigner: false,
|
|
||||||
signingOrder: DocumentSigningOrder.PARALLEL,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
allowDictateNextSigner: false,
|
|
||||||
signingOrder: DocumentSigningOrder.PARALLEL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test both recipients can sign in parallel
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
const { token, fields } = recipient;
|
|
||||||
const signUrl = `/sign/${token}`;
|
|
||||||
|
|
||||||
await page.goto(signUrl);
|
|
||||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
|
||||||
|
|
||||||
await signSignaturePad(page);
|
|
||||||
|
|
||||||
// Fill in all fields
|
|
||||||
for (const field of fields) {
|
|
||||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
|
||||||
|
|
||||||
if (field.type === FieldType.TEXT) {
|
|
||||||
await page.locator('#custom-text').fill('TEXT');
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete signing
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
|
||||||
|
|
||||||
// Verify next recipient UI is not shown in parallel flow
|
|
||||||
await expect(
|
|
||||||
page.getByText('The next recipient to sign this document will be'),
|
|
||||||
).not.toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
|
|
||||||
|
|
||||||
// Submit and verify completion
|
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
|
||||||
await page.waitForURL(`${signUrl}/complete`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify final document and recipient states
|
|
||||||
await expect(async () => {
|
|
||||||
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
|
||||||
where: { id: document.id },
|
|
||||||
include: {
|
|
||||||
recipients: {
|
|
||||||
orderBy: { signingOrder: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Document should be completed since all recipients have signed
|
|
||||||
expect(updatedDocument.status).toBe(DocumentStatus.COMPLETED);
|
|
||||||
|
|
||||||
// All recipients should be completed
|
|
||||||
for (const recipient of updatedDocument.recipients) {
|
|
||||||
expect(recipient.signingStatus).toBe(SigningStatus.SIGNED);
|
|
||||||
}
|
|
||||||
}).toPass();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const user = await seedUser();
|
|
||||||
const assistant = await seedUser();
|
|
||||||
const signer = await seedUser();
|
|
||||||
const thirdSigner = await seedUser();
|
|
||||||
|
|
||||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
|
||||||
owner: user,
|
|
||||||
recipients: [assistant, signer, thirdSigner],
|
|
||||||
recipientsCreateOptions: [
|
|
||||||
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
|
|
||||||
{ signingOrder: 2, role: RecipientRole.SIGNER },
|
|
||||||
{ signingOrder: 3, role: RecipientRole.SIGNER },
|
|
||||||
],
|
|
||||||
updateDocumentOptions: {
|
|
||||||
documentMeta: {
|
|
||||||
upsert: {
|
|
||||||
create: {
|
|
||||||
allowDictateNextSigner: true,
|
|
||||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
allowDictateNextSigner: true,
|
|
||||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistantRecipient = recipients[0];
|
|
||||||
const { token, fields } = assistantRecipient;
|
|
||||||
|
|
||||||
const signUrl = `/sign/${token}`;
|
|
||||||
|
|
||||||
await page.goto(signUrl);
|
|
||||||
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('radio', { name: assistantRecipient.name }).click();
|
|
||||||
|
|
||||||
// Fill in all fields
|
|
||||||
for (const field of fields) {
|
|
||||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
|
||||||
|
|
||||||
if (field.type === FieldType.SIGNATURE) {
|
|
||||||
await signDirectSignaturePad(page);
|
|
||||||
await page.getByRole('button', { name: 'Sign', exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === FieldType.TEXT) {
|
|
||||||
await page.locator('#custom-text').fill('TEXT');
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete assisting and update next recipient
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
// Use dialog context to ensure we're targeting the correct form fields
|
|
||||||
const dialog = page.getByRole('dialog');
|
|
||||||
await dialog.getByLabel('Name').fill('New Signer');
|
|
||||||
await dialog.getByLabel('Email').fill('new.signer@example.com');
|
|
||||||
|
|
||||||
// Submit and verify completion
|
|
||||||
await page.getByRole('button', { name: /Continue|Proceed/i }).click();
|
|
||||||
await page.waitForURL(`${signUrl}/complete`);
|
|
||||||
|
|
||||||
// Verify document and recipient states
|
|
||||||
await expect(async () => {
|
|
||||||
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
|
||||||
where: { id: document.id },
|
|
||||||
include: {
|
|
||||||
recipients: {
|
|
||||||
orderBy: { signingOrder: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Document should still be pending as there are more recipients
|
|
||||||
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
|
|
||||||
|
|
||||||
// Assistant should be completed
|
|
||||||
const updatedAssistant = updatedDocument.recipients[0];
|
|
||||||
expect(updatedAssistant.signingStatus).toBe(SigningStatus.SIGNED);
|
|
||||||
expect(updatedAssistant.role).toBe(RecipientRole.ASSISTANT);
|
|
||||||
|
|
||||||
// Second recipient should be the new signer
|
|
||||||
const updatedSigner = updatedDocument.recipients[1];
|
|
||||||
expect(updatedSigner.name).toBe('New Signer');
|
|
||||||
expect(updatedSigner.email).toBe('new.signer@example.com');
|
|
||||||
expect(updatedSigner.signingOrder).toBe(2);
|
|
||||||
expect(updatedSigner.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
|
||||||
expect(updatedSigner.role).toBe(RecipientRole.SIGNER);
|
|
||||||
|
|
||||||
// Third recipient should remain unchanged
|
|
||||||
const thirdRecipient = updatedDocument.recipients[2];
|
|
||||||
expect(thirdRecipient.email).toBe(thirdSigner.email);
|
|
||||||
expect(thirdRecipient.signingOrder).toBe(3);
|
|
||||||
expect(thirdRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
|
||||||
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
|
|
||||||
}).toPass();
|
|
||||||
});
|
|
||||||
@@ -377,9 +377,7 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
|
|||||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
await page
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
|
|
||||||
.click();
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
|
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
|
||||||
.click();
|
.click();
|
||||||
@@ -449,7 +447,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
|||||||
const { status } = await getDocumentByToken(token);
|
const { status } = await getDocumentByToken(token);
|
||||||
expect(status).toBe(DocumentStatus.PENDING);
|
expect(status).toBe(DocumentStatus.PENDING);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Approve' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
|
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Approve' }).click();
|
await page.getByRole('button', { name: 'Approve' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -222,10 +222,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
|
|
||||||
// Toggle signing certificate setting
|
// Toggle signing certificate setting
|
||||||
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
||||||
await page
|
await page.getByRole('button', { name: /Save/ }).first().click();
|
||||||
.getByRole('button', { name: /Update/ })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
@@ -239,10 +236,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
|
|
||||||
// Toggle the setting back to true
|
// Toggle the setting back to true
|
||||||
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
||||||
await page
|
await page.getByRole('button', { name: /Save/ }).first().click();
|
||||||
.getByRole('button', { name: /Update/ })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
|
||||||
* Will open the signature pad dialog and sign it.
|
|
||||||
*/
|
|
||||||
export const signSignaturePad = async (page: Page) => {
|
export const signSignaturePad = async (page: Page) => {
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
await page.getByTestId('signature-pad-dialog-button').click();
|
const canvas = page.getByTestId('signature-pad');
|
||||||
|
|
||||||
// Click type tab
|
const box = await canvas.boundingBox();
|
||||||
await page.getByRole('tab', { name: 'Type' }).click();
|
|
||||||
await page.getByTestId('signature-pad-type-input').fill('Signature');
|
|
||||||
|
|
||||||
// Click Next button
|
if (!box) {
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
throw new Error('Signature pad not found');
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
// Calculate center point
|
||||||
* For when the signature pad is already open.
|
const centerX = box.x + box.width / 2;
|
||||||
*/
|
const centerY = box.y + box.height / 2;
|
||||||
export const signDirectSignaturePad = async (page: Page) => {
|
|
||||||
await page.waitForTimeout(200);
|
// Calculate square size (making it slightly smaller than the canvas)
|
||||||
|
const squareSize = Math.min(box.width, box.height) * 0.4; // 40% of the smallest dimension
|
||||||
// Click type tab
|
|
||||||
await page.getByRole('tab', { name: 'Type' }).click();
|
// Move to center
|
||||||
await page.getByTestId('signature-pad-type-input').fill('Signature');
|
await page.mouse.move(centerX, centerY);
|
||||||
|
await page.mouse.down();
|
||||||
|
|
||||||
|
// Draw square clockwise from center
|
||||||
|
// Move right
|
||||||
|
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
|
||||||
|
// Move down
|
||||||
|
await page.mouse.move(centerX + squareSize, centerY + squareSize, { steps: 10 });
|
||||||
|
// Move left
|
||||||
|
await page.mouse.move(centerX - squareSize, centerY + squareSize, { steps: 10 });
|
||||||
|
// Move up
|
||||||
|
await page.mouse.move(centerX - squareSize, centerY - squareSize, { steps: 10 });
|
||||||
|
// Move right
|
||||||
|
await page.mouse.move(centerX + squareSize, centerY - squareSize, { steps: 10 });
|
||||||
|
// Move down to close the square
|
||||||
|
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
|
||||||
|
|
||||||
|
await page.mouse.up();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
|
|||||||
// Go back to public profile page.
|
// Go back to public profile page.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
|
||||||
await page.getByRole('switch').click();
|
await page.getByRole('switch').click();
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Assert values.
|
// Assert values.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||||
@@ -128,7 +127,6 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
|||||||
// Go back to public profile page.
|
// Go back to public profile page.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
|
||||||
await page.getByRole('switch').click();
|
await page.getByRole('switch').click();
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Assert values.
|
// Assert values.
|
||||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test('[TEAMS]: update the default document visibility in the team global setting
|
|||||||
// !: Brittle selector
|
// !: Brittle selector
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
await page.getByRole('option', { name: 'Admin' }).click();
|
await page.getByRole('option', { name: 'Admin' }).click();
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||||
await expect(toast).toBeVisible();
|
await expect(toast).toBeVisible();
|
||||||
@@ -47,7 +47,7 @@ test('[TEAMS]: update the sender details in the team global settings', async ({
|
|||||||
|
|
||||||
await expect(checkbox).toBeChecked();
|
await expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||||
await expect(toast).toBeVisible();
|
await expect(toast).toBeVisible();
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import {
|
|
||||||
seedTeamDocumentWithMeta,
|
|
||||||
seedTeamDocuments,
|
|
||||||
seedTeamTemplateWithMeta,
|
|
||||||
} from '@documenso/prisma/seed/documents';
|
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
|
|
||||||
test('[TEAMS]: check that default team signature settings are all enabled', async ({ page }) => {
|
|
||||||
const { team } = await seedTeamDocuments();
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: team.owner.email,
|
|
||||||
password: 'password',
|
|
||||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify that the default created team settings has all signatures enabled
|
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
|
|
||||||
|
|
||||||
const document = await seedTeamDocumentWithMeta(team);
|
|
||||||
|
|
||||||
// Create a document and check the settings
|
|
||||||
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
|
|
||||||
|
|
||||||
// Verify that the settings match
|
|
||||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
|
|
||||||
|
|
||||||
// Go to document and check that the signatured tabs are correct.
|
|
||||||
await page.goto(`/sign/${document.recipients[0].token}`);
|
|
||||||
await page.getByTestId('signature-pad-dialog-button').click();
|
|
||||||
|
|
||||||
// Check the tab values
|
|
||||||
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
|
||||||
const { team } = await seedTeamDocuments();
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: team.owner.email,
|
|
||||||
password: 'password',
|
|
||||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
|
||||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
|
||||||
|
|
||||||
for (const tabs of tabTest) {
|
|
||||||
await page.goto(`/t/${team.url}/settings/preferences`);
|
|
||||||
|
|
||||||
// Update combobox to have the correct tabs
|
|
||||||
await page.getByTestId('signature-types-combobox').click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
|
||||||
|
|
||||||
// Clear all selected items.
|
|
||||||
for (const tab of allTabs) {
|
|
||||||
const item = page.getByRole('option', { name: tab });
|
|
||||||
|
|
||||||
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
await item.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selected wanted items.
|
|
||||||
for (const tab of tabs) {
|
|
||||||
const item = page.getByRole('option', { name: tab });
|
|
||||||
await item.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
|
||||||
|
|
||||||
const document = await seedTeamDocumentWithMeta(team);
|
|
||||||
|
|
||||||
// Go to document and check that the signatured tabs are correct.
|
|
||||||
await page.goto(`/sign/${document.recipients[0].token}`);
|
|
||||||
await page.getByTestId('signature-pad-dialog-button').click();
|
|
||||||
|
|
||||||
// Check the tab values
|
|
||||||
for (const tab of allTabs) {
|
|
||||||
if (tabs.includes(tab)) {
|
|
||||||
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
|
|
||||||
} else {
|
|
||||||
await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
|
||||||
const { team } = await seedTeamDocuments();
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: team.owner.email,
|
|
||||||
password: 'password',
|
|
||||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
|
||||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
|
||||||
|
|
||||||
for (const tabs of tabTest) {
|
|
||||||
await page.goto(`/t/${team.url}/settings/preferences`);
|
|
||||||
|
|
||||||
// Update combobox to have the correct tabs
|
|
||||||
await page.getByTestId('signature-types-combobox').click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
|
||||||
|
|
||||||
// Clear all selected items.
|
|
||||||
for (const tab of allTabs) {
|
|
||||||
const item = page.getByRole('option', { name: tab });
|
|
||||||
|
|
||||||
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
await item.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selected wanted items.
|
|
||||||
for (const tab of tabs) {
|
|
||||||
const item = page.getByRole('option', { name: tab });
|
|
||||||
await item.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
|
||||||
|
|
||||||
const template = await seedTeamTemplateWithMeta(team);
|
|
||||||
|
|
||||||
await page.goto(`/t/${team.url}/templates/${template.id}`);
|
|
||||||
await page.getByRole('button', { name: 'Use' }).click();
|
|
||||||
|
|
||||||
// Check the send document checkbox to true
|
|
||||||
await page.getByLabel('Send document').click();
|
|
||||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirst({
|
|
||||||
where: {
|
|
||||||
templateId: template.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
documentMeta: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test kinda flaky, debug here.
|
|
||||||
// console.log({
|
|
||||||
// tabs,
|
|
||||||
// typedSignatureEnabled: document?.documentMeta?.typedSignatureEnabled,
|
|
||||||
// uploadSignatureEnabled: document?.documentMeta?.uploadSignatureEnabled,
|
|
||||||
// drawSignatureEnabled: document?.documentMeta?.drawSignatureEnabled,
|
|
||||||
// });
|
|
||||||
|
|
||||||
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
|
|
||||||
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
|
|
||||||
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -298,7 +298,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({
|
|||||||
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
|
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-emai
|
|||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
import { signSignaturePad } from '../fixtures/signature';
|
|
||||||
|
|
||||||
test('[USER] update full name', async ({ page }) => {
|
test('[USER] update full name', async ({ page }) => {
|
||||||
const user = await seedUser();
|
const user = await seedUser();
|
||||||
@@ -13,7 +12,7 @@ test('[USER] update full name', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByLabel('Full Name').fill('John Doe');
|
await page.getByLabel('Full Name').fill('John Doe');
|
||||||
|
|
||||||
await signSignaturePad(page);
|
await page.getByPlaceholder('Type your signature').fill('John Doe');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Update profile' }).click();
|
await page.getByRole('button', { name: 'Update profile' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ export interface TemplateDocumentCancelProps {
|
|||||||
inviterEmail: string;
|
inviterEmail: string;
|
||||||
documentName: string;
|
documentName: string;
|
||||||
assetBaseUrl: string;
|
assetBaseUrl: string;
|
||||||
cancellationReason?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateDocumentCancel = ({
|
export const TemplateDocumentCancel = ({
|
||||||
inviterName,
|
inviterName,
|
||||||
documentName,
|
documentName,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
cancellationReason,
|
|
||||||
}: TemplateDocumentCancelProps) => {
|
}: TemplateDocumentCancelProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -36,12 +34,6 @@ export const TemplateDocumentCancel = ({
|
|||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
<Trans>You don't need to sign it anymore.</Trans>
|
<Trans>You don't need to sign it anymore.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{cancellationReason && (
|
|
||||||
<Text className="mt-4 text-center text-base">
|
|
||||||
<Trans>Reason for cancellation: {cancellationReason}</Trans>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const DocumentCancelTemplate = ({
|
|||||||
inviterEmail = 'lucas@documenso.com',
|
inviterEmail = 'lucas@documenso.com',
|
||||||
documentName = 'Open Source Pledge.pdf',
|
documentName = 'Open Source Pledge.pdf',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
cancellationReason,
|
|
||||||
}: DocumentCancelEmailTemplateProps) => {
|
}: DocumentCancelEmailTemplateProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
@@ -49,7 +48,6 @@ export const DocumentCancelTemplate = ({
|
|||||||
inviterEmail={inviterEmail}
|
inviterEmail={inviterEmail}
|
||||||
documentName={documentName}
|
documentName={documentName}
|
||||||
assetBaseUrl={assetBaseUrl}
|
assetBaseUrl={assetBaseUrl}
|
||||||
cancellationReason={cancellationReason}
|
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ export const DOCUMENT_STATUS: {
|
|||||||
[DocumentStatus.COMPLETED]: {
|
[DocumentStatus.COMPLETED]: {
|
||||||
description: msg`Completed`,
|
description: msg`Completed`,
|
||||||
},
|
},
|
||||||
[DocumentStatus.REJECTED]: {
|
|
||||||
description: msg`Rejected`,
|
|
||||||
},
|
|
||||||
[DocumentStatus.DRAFT]: {
|
[DocumentStatus.DRAFT]: {
|
||||||
description: msg`Draft`,
|
description: msg`Draft`,
|
||||||
},
|
},
|
||||||
@@ -34,29 +31,3 @@ export const DOCUMENT_DISTRIBUTION_METHODS: Record<string, DocumentDistributionM
|
|||||||
description: msg`None`,
|
description: msg`None`,
|
||||||
},
|
},
|
||||||
} satisfies Record<DocumentDistributionMethod, DocumentDistributionMethodTypeData>;
|
} satisfies Record<DocumentDistributionMethod, DocumentDistributionMethodTypeData>;
|
||||||
|
|
||||||
export enum DocumentSignatureType {
|
|
||||||
DRAW = 'draw',
|
|
||||||
TYPE = 'type',
|
|
||||||
UPLOAD = 'upload',
|
|
||||||
}
|
|
||||||
|
|
||||||
type DocumentSignatureTypeData = {
|
|
||||||
label: MessageDescriptor;
|
|
||||||
value: DocumentSignatureType;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DOCUMENT_SIGNATURE_TYPES = {
|
|
||||||
[DocumentSignatureType.DRAW]: {
|
|
||||||
label: msg`Draw`,
|
|
||||||
value: DocumentSignatureType.DRAW,
|
|
||||||
},
|
|
||||||
[DocumentSignatureType.TYPE]: {
|
|
||||||
label: msg`Type`,
|
|
||||||
value: DocumentSignatureType.TYPE,
|
|
||||||
},
|
|
||||||
[DocumentSignatureType.UPLOAD]: {
|
|
||||||
label: msg`Upload`,
|
|
||||||
value: DocumentSignatureType.UPLOAD,
|
|
||||||
},
|
|
||||||
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export const SIGNATURE_CANVAS_DPI = 2;
|
|
||||||
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
|
|
||||||
|
|
||||||
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { JobClient } from './client/client';
|
import { JobClient } from './client/client';
|
||||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
||||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
|
||||||
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
||||||
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
||||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||||
@@ -25,7 +24,6 @@ export const jobsClient = new JobClient([
|
|||||||
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
||||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
|
||||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import { createElement } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
|
||||||
|
|
||||||
import { mailer } from '@documenso/email/mailer';
|
|
||||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
|
||||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
|
||||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
|
||||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
|
||||||
import type { JobRunIO } from '../../client/_internal/job';
|
|
||||||
import type { TSendDocumentCancelledEmailsJobDefinition } from './send-document-cancelled-emails';
|
|
||||||
|
|
||||||
export const run = async ({
|
|
||||||
payload,
|
|
||||||
io,
|
|
||||||
}: {
|
|
||||||
payload: TSendDocumentCancelledEmailsJobDefinition;
|
|
||||||
io: JobRunIO;
|
|
||||||
}) => {
|
|
||||||
const { documentId, cancellationReason } = payload;
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
id: documentId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
documentMeta: true,
|
|
||||||
recipients: true,
|
|
||||||
team: {
|
|
||||||
select: {
|
|
||||||
teamEmail: true,
|
|
||||||
name: true,
|
|
||||||
url: true,
|
|
||||||
teamGlobalSettings: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { documentMeta, user: documentOwner } = document;
|
|
||||||
|
|
||||||
// Check if document cancellation emails are enabled
|
|
||||||
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
|
|
||||||
|
|
||||||
if (!isEmailEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const i18n = await getI18nInstance(documentMeta?.language);
|
|
||||||
|
|
||||||
// Send cancellation emails to all recipients who have been sent the document or viewed it
|
|
||||||
const recipientsToNotify = document.recipients.filter(
|
|
||||||
(recipient) =>
|
|
||||||
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
|
||||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
|
||||||
);
|
|
||||||
|
|
||||||
await io.runTask('send-cancellation-emails', async () => {
|
|
||||||
await Promise.all(
|
|
||||||
recipientsToNotify.map(async (recipient) => {
|
|
||||||
const template = createElement(DocumentCancelTemplate, {
|
|
||||||
documentName: document.title,
|
|
||||||
inviterName: documentOwner.name || undefined,
|
|
||||||
inviterEmail: documentOwner.email,
|
|
||||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
|
||||||
cancellationReason: cancellationReason || 'The document has been cancelled.',
|
|
||||||
});
|
|
||||||
|
|
||||||
const branding = document.team?.teamGlobalSettings
|
|
||||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const [html, text] = await Promise.all([
|
|
||||||
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
|
|
||||||
renderEmailWithI18N(template, {
|
|
||||||
lang: documentMeta?.language,
|
|
||||||
branding,
|
|
||||||
plainText: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await mailer.sendMail({
|
|
||||||
to: {
|
|
||||||
name: recipient.name,
|
|
||||||
address: recipient.email,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
name: FROM_NAME,
|
|
||||||
address: FROM_ADDRESS,
|
|
||||||
},
|
|
||||||
subject: i18n._(msg`Document "${document.title}" Cancelled`),
|
|
||||||
html,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { type JobDefinition } from '../../client/_internal/job';
|
|
||||||
|
|
||||||
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID = 'send.document.cancelled.emails';
|
|
||||||
|
|
||||||
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
|
|
||||||
documentId: z.number(),
|
|
||||||
cancellationReason: z.string().optional(),
|
|
||||||
requestMetadata: z.any().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TSendDocumentCancelledEmailsJobDefinition = z.infer<
|
|
||||||
typeof SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION = {
|
|
||||||
id: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
|
|
||||||
name: 'Send Document Cancelled Emails',
|
|
||||||
version: '1.0.0',
|
|
||||||
trigger: {
|
|
||||||
name: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
|
|
||||||
schema: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA,
|
|
||||||
},
|
|
||||||
handler: async ({ payload, io }) => {
|
|
||||||
const handler = await import('./send-document-cancelled-emails.handler');
|
|
||||||
|
|
||||||
await handler.run({ payload, io });
|
|
||||||
},
|
|
||||||
} as const satisfies JobDefinition<
|
|
||||||
typeof SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
|
|
||||||
TSendDocumentCancelledEmailsJobDefinition
|
|
||||||
>;
|
|
||||||
@@ -23,8 +23,6 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
|||||||
brandingHidePoweredBy: z.boolean(),
|
brandingHidePoweredBy: z.boolean(),
|
||||||
teamId: z.number(),
|
teamId: z.number(),
|
||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
uploadSignatureEnabled: z.boolean(),
|
|
||||||
drawSignatureEnabled: z.boolean(),
|
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { PDFDocument } from 'pdf-lib';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { signPdf } from '@documenso/signing';
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
|
||||||
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
|
||||||
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
|
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
|
||||||
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
|
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
|
||||||
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
|
|
||||||
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
||||||
import { flattenForm } from '../../../server-only/pdf/flatten-form';
|
import { flattenForm } from '../../../server-only/pdf/flatten-form';
|
||||||
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
|
||||||
@@ -24,7 +22,6 @@ import {
|
|||||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||||
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
|
||||||
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
||||||
import { isDocumentCompleted } from '../../../utils/document';
|
|
||||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||||
import type { JobRunIO } from '../../client/_internal/job';
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
import type { TSealDocumentJobDefinition } from './seal-document';
|
import type { TSealDocumentJobDefinition } from './seal-document';
|
||||||
@@ -41,6 +38,11 @@ export const run = async ({
|
|||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
|
recipients: {
|
||||||
|
every: {
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
@@ -57,16 +59,6 @@ export const run = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isComplete =
|
|
||||||
document.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
|
||||||
document.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
|
||||||
|
|
||||||
if (!isComplete) {
|
|
||||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
||||||
message: 'Document is not complete',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seems silly but we need to do this in case the job is re-ran
|
// Seems silly but we need to do this in case the job is re-ran
|
||||||
// after it has already run through the update task further below.
|
// after it has already run through the update task further below.
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
@@ -99,15 +91,9 @@ export const run = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine if the document has been rejected by checking if any recipient has rejected it
|
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
|
||||||
const rejectedRecipient = recipients.find(
|
throw new Error(`Document ${document.id} has unsigned recipients`);
|
||||||
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const isRejected = Boolean(rejectedRecipient);
|
|
||||||
|
|
||||||
// Get the rejection reason from the rejected recipient
|
|
||||||
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
|
||||||
|
|
||||||
const fields = await prisma.field.findMany({
|
const fields = await prisma.field.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -118,8 +104,7 @@ export const run = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip the field check if the document is rejected
|
if (fieldsContainUnsignedRequiredField(fields)) {
|
||||||
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
|
||||||
throw new Error(`Document ${document.id} has unsigned required fields`);
|
throw new Error(`Document ${document.id} has unsigned required fields`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +132,6 @@ export const run = async ({
|
|||||||
flattenForm(pdfDoc);
|
flattenForm(pdfDoc);
|
||||||
flattenAnnotations(pdfDoc);
|
flattenAnnotations(pdfDoc);
|
||||||
|
|
||||||
// Add rejection stamp if the document is rejected
|
|
||||||
if (isRejected && rejectionReason) {
|
|
||||||
await addRejectionStampToPdf(pdfDoc, rejectionReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (certificateData) {
|
if (certificateData) {
|
||||||
const certificateDoc = await PDFDocument.load(certificateData);
|
const certificateDoc = await PDFDocument.load(certificateData);
|
||||||
|
|
||||||
@@ -180,11 +160,8 @@ export const run = async ({
|
|||||||
|
|
||||||
const { name } = path.parse(document.title);
|
const { name } = path.parse(document.title);
|
||||||
|
|
||||||
// Add suffix based on document status
|
|
||||||
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
|
||||||
|
|
||||||
const documentData = await putPdfFileServerSide({
|
const documentData = await putPdfFileServerSide({
|
||||||
name: `${name}${suffix}`,
|
name: `${name}_signed.pdf`,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||||
});
|
});
|
||||||
@@ -200,7 +177,6 @@ export const run = async ({
|
|||||||
event: 'App: Document Sealed',
|
event: 'App: Document Sealed',
|
||||||
properties: {
|
properties: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
isRejected,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -218,7 +194,7 @@ export const run = async ({
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -240,7 +216,6 @@ export const run = async ({
|
|||||||
user: null,
|
user: null,
|
||||||
data: {
|
data: {
|
||||||
transactionId: nanoid(),
|
transactionId: nanoid(),
|
||||||
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -248,9 +223,9 @@ export const run = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await io.runTask('send-completed-email', async () => {
|
await io.runTask('send-completed-email', async () => {
|
||||||
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
|
let shouldSendCompletedEmail = sendEmail && !isResealing;
|
||||||
|
|
||||||
if (isResealing && !isDocumentCompleted(document.status)) {
|
if (isResealing && documentStatus !== DocumentStatus.COMPLETED) {
|
||||||
shouldSendCompletedEmail = sendEmail;
|
shouldSendCompletedEmail = sendEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,9 +246,7 @@ export const run = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
event: isRejected
|
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||||
? WebhookTriggerEvents.DOCUMENT_REJECTED
|
|
||||||
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
|
||||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||||
userId: updatedDocument.userId,
|
userId: updatedDocument.userId,
|
||||||
teamId: updatedDocument.teamId ?? undefined,
|
teamId: updatedDocument.teamId ?? undefined,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export const getDocumentStats = async () => {
|
|||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ export async function getSigningVolume({
|
|||||||
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||||
.on('td.deletedAt', 'is', null),
|
.on('td.deletedAt', 'is', null),
|
||||||
)
|
)
|
||||||
|
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
|
||||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([
|
eb.or([
|
||||||
@@ -81,6 +82,7 @@ export async function getSigningVolume({
|
|||||||
.selectFrom('Subscription as s')
|
.selectFrom('Subscription as s')
|
||||||
.leftJoin('User as u', 's.userId', 'u.id')
|
.leftJoin('User as u', 's.userId', 'u.id')
|
||||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||||
|
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
|
||||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([
|
eb.or([
|
||||||
|
|||||||
@@ -24,11 +24,8 @@ export type CreateDocumentMetaOptions = {
|
|||||||
redirectUrl?: string;
|
redirectUrl?: string;
|
||||||
emailSettings?: TDocumentEmailSettings;
|
emailSettings?: TDocumentEmailSettings;
|
||||||
signingOrder?: DocumentSigningOrder;
|
signingOrder?: DocumentSigningOrder;
|
||||||
allowDictateNextSigner?: boolean;
|
|
||||||
distributionMethod?: DocumentDistributionMethod;
|
distributionMethod?: DocumentDistributionMethod;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
|
||||||
drawSignatureEnabled?: boolean;
|
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@@ -44,12 +41,9 @@ export const upsertDocumentMeta = async ({
|
|||||||
password,
|
password,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
allowDictateNextSigner,
|
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
|
||||||
drawSignatureEnabled,
|
|
||||||
language,
|
language,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
@@ -99,12 +93,9 @@ export const upsertDocumentMeta = async ({
|
|||||||
documentId,
|
documentId,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
allowDictateNextSigner,
|
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
|
||||||
drawSignatureEnabled,
|
|
||||||
language,
|
language,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -115,12 +106,9 @@ export const upsertDocumentMeta = async ({
|
|||||||
timezone,
|
timezone,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
allowDictateNextSigner,
|
|
||||||
emailSettings,
|
emailSettings,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
|
||||||
drawSignatureEnabled,
|
|
||||||
language,
|
language,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import {
|
|||||||
WebhookTriggerEvents,
|
WebhookTriggerEvents,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
DOCUMENT_AUDIT_LOG_TYPE,
|
|
||||||
RECIPIENT_DIFF_TYPE,
|
|
||||||
} from '@documenso/lib/types/document-audit-logs';
|
|
||||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
@@ -33,10 +30,6 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
authOptions?: TRecipientActionAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
nextSigner?: {
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||||
@@ -64,7 +57,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
nextSigner,
|
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
const document = await getDocument({ token, documentId });
|
const document = await getDocument({ token, documentId });
|
||||||
|
|
||||||
@@ -154,6 +146,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
recipientName: recipient.name,
|
recipientName: recipient.name,
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
recipientRole: recipient.role,
|
recipientRole: recipient.role,
|
||||||
|
// actionAuth: derivedRecipientActionAuth || undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -171,9 +164,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
signingOrder: true,
|
signingOrder: true,
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@@ -196,49 +186,9 @@ export const completeDocumentWithToken = async ({
|
|||||||
const [nextRecipient] = pendingRecipients;
|
const [nextRecipient] = pendingRecipients;
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
if (nextSigner && document.documentMeta?.allowDictateNextSigner) {
|
|
||||||
await tx.documentAuditLog.create({
|
|
||||||
data: createDocumentAuditLogData({
|
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
|
||||||
documentId: document.id,
|
|
||||||
user: {
|
|
||||||
name: recipient.name,
|
|
||||||
email: recipient.email,
|
|
||||||
},
|
|
||||||
requestMetadata,
|
|
||||||
data: {
|
|
||||||
recipientEmail: nextRecipient.email,
|
|
||||||
recipientName: nextRecipient.name,
|
|
||||||
recipientId: nextRecipient.id,
|
|
||||||
recipientRole: nextRecipient.role,
|
|
||||||
changes: [
|
|
||||||
{
|
|
||||||
type: RECIPIENT_DIFF_TYPE.NAME,
|
|
||||||
from: nextRecipient.name,
|
|
||||||
to: nextSigner.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
|
||||||
from: nextRecipient.email,
|
|
||||||
to: nextSigner.email,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.recipient.update({
|
await tx.recipient.update({
|
||||||
where: { id: nextRecipient.id },
|
where: { id: nextRecipient.id },
|
||||||
data: {
|
data: { sendStatus: SendStatus.SENT },
|
||||||
sendStatus: SendStatus.SENT,
|
|
||||||
...(nextSigner && document.documentMeta?.allowDictateNextSigner
|
|
||||||
? {
|
|
||||||
name: nextSigner.name,
|
|
||||||
email: nextSigner.email,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
|
|||||||
@@ -158,10 +158,6 @@ export const createDocumentV2 = async ({
|
|||||||
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
|
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
|
||||||
typedSignatureEnabled:
|
typedSignatureEnabled:
|
||||||
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
|
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled:
|
|
||||||
meta?.uploadSignatureEnabled ?? team?.teamGlobalSettings?.uploadSignatureEnabled,
|
|
||||||
drawSignatureEnabled:
|
|
||||||
meta?.drawSignatureEnabled ?? team?.teamGlobalSettings?.drawSignatureEnabled,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -128,10 +128,8 @@ export const createDocument = async ({
|
|||||||
documentMeta: {
|
documentMeta: {
|
||||||
create: {
|
create: {
|
||||||
language: team?.teamGlobalSettings?.documentLanguage,
|
language: team?.teamGlobalSettings?.documentLanguage,
|
||||||
|
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled,
|
||||||
timezone: timezone,
|
timezone: timezone,
|
||||||
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled ?? true,
|
|
||||||
uploadSignatureEnabled: team?.teamGlobalSettings?.uploadSignatureEnabled ?? true,
|
|
||||||
drawSignatureEnabled: team?.teamGlobalSettings?.drawSignatureEnabled ?? true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
mapDocumentToWebhookDocumentPayload,
|
mapDocumentToWebhookDocumentPayload,
|
||||||
} from '../../types/webhook-payload';
|
} from '../../types/webhook-payload';
|
||||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { isDocumentCompleted } from '../../utils/document';
|
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||||
@@ -162,7 +161,7 @@ const handleDocumentOwnerDelete = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Soft delete completed documents.
|
// Soft delete completed documents.
|
||||||
if (isDocumentCompleted(document.status)) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
data: createDocumentAuditLogData({
|
data: createDocumentAuditLogData({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user