mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
19 Commits
fix/font-s
...
v1.13.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 88836404d1 | |||
| 2eebc0e439 | |||
| 4a3859ec60 | |||
| 49b792503f | |||
| c3dc76b1b4 | |||
| daab8461c7 | |||
| 1ffc4bd703 | |||
| f15c0778b5 | |||
| 06cb8b1f23 | |||
| 7f09ba72f4 | |||
| 7b17156e56 | |||
| 86e89e137e | |||
| 26f65dbdd7 | |||
| a902bec96d | |||
| 399f91de73 | |||
| 995bc9c362 | |||
| 3467317271 | |||
| a5eaa8ad47 | |||
| 577691214b |
@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
# Find documentation on setting up Microsoft OAuth here:
|
||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
|
||||
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -56,4 +56,7 @@ logs.json
|
||||
|
||||
# claude
|
||||
.claude
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
|
||||
# agents
|
||||
.specs
|
||||
|
||||
@ -27,3 +27,33 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
|
||||
```
|
||||
|
||||
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
||||
|
||||
## Microsoft OAuth (Azure AD)
|
||||
|
||||
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
|
||||
|
||||
### Create and configure a new Azure AD application
|
||||
|
||||
1. Go to the [Azure Portal](https://portal.azure.com/)
|
||||
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
|
||||
3. In the left sidebar, click **App registrations**
|
||||
4. Click **New registration**
|
||||
5. Enter a name for your application (e.g., "Documenso")
|
||||
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
|
||||
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
|
||||
8. Click **Register**
|
||||
|
||||
### Configure the application
|
||||
|
||||
1. After registration, you'll be taken to the app's overview page
|
||||
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
|
||||
3. In the left sidebar, click **Certificates & secrets**
|
||||
4. Under **Client secrets**, click **New client secret**
|
||||
5. Add a description and select an expiration period
|
||||
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
|
||||
7. In the Documenso environment variables, set the following:
|
||||
|
||||
```
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
|
||||
```
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
@ -7,18 +7,19 @@ import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Document')
|
||||
.selectFrom('Envelope')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
|
||||
fn.count('id').as('count'),
|
||||
fn
|
||||
.sum(fn.count('id'))
|
||||
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -22,10 +21,10 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminDocumentDeleteDialogProps = {
|
||||
document: Document;
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => {
|
||||
export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -42,7 +41,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteDocument({ id: document.id, reason });
|
||||
await deleteDocument({ id: envelopeId, reason });
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
|
||||
@ -57,14 +57,14 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||
trpcReact.document.duplicate.useMutation({
|
||||
onSuccess: async ({ documentId }) => {
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: _(msg`Document Duplicated`),
|
||||
description: _(msg`Your document has been successfully duplicated.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${documentsPath}/${documentId}/edit`);
|
||||
await navigate(`${documentsPath}/${id}/edit`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
@ -81,7 +81,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
||||
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -94,9 +94,11 @@ export const DocumentMoveToFolderDialog = ({
|
||||
|
||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||
try {
|
||||
await moveDocumentToFolder({
|
||||
await updateDocument({
|
||||
documentId,
|
||||
folderId: data.folderId ?? null,
|
||||
data: {
|
||||
folderId: data.folderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
@ -4,15 +4,15 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -43,7 +43,11 @@ import { StackAvatar } from '../general/stack-avatar';
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type DocumentResendDialogProps = {
|
||||
document: TDocumentRow;
|
||||
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
|
||||
449
apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
Normal file
449
apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
Normal file
@ -0,0 +1,449 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
} from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
};
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
meta: z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().email().optional(),
|
||||
),
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
distributionMethod: z
|
||||
.nativeEnum(DocumentDistributionMethod)
|
||||
.optional()
|
||||
.default(DocumentDistributionMethod.EMAIL),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||
|
||||
export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistributeDialogProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const recipients = envelope.recipients;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
|
||||
|
||||
const form = useForm<TEnvelopeDistributeFormSchema>({
|
||||
defaultValues: {
|
||||
meta: {
|
||||
emailId: envelope.documentMeta?.emailId ?? null,
|
||||
emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined,
|
||||
subject: envelope.documentMeta?.subject ?? '',
|
||||
message: envelope.documentMeta?.message ?? '',
|
||||
distributionMethod:
|
||||
envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||
},
|
||||
},
|
||||
resolver: zodResolver(ZEnvelopeDistributeFormSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
const distributionMethod = watch('meta.distributionMethod');
|
||||
|
||||
const everySignerHasSignature = useMemo(
|
||||
() =>
|
||||
envelope.recipients
|
||||
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
|
||||
.every((recipient) =>
|
||||
envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
[envelope.recipients, envelope.fields],
|
||||
);
|
||||
|
||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||
try {
|
||||
await distributeEnvelope({ envelopeId: envelope.id, meta });
|
||||
|
||||
toast({
|
||||
title: t`Envelope distributed`,
|
||||
description: t`Your envelope has been distributed successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This envelope could not be distributed at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Send Document</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Recipients will be able to sign the document once sent</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{everySignerHasSignature ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
<Tabs
|
||||
onValueChange={(value) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
setValue('meta.distributionMethod', value as DocumentDistributionMethod)
|
||||
}
|
||||
value={distributionMethod}
|
||||
className="mb-2"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
|
||||
Email
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
|
||||
None
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="min-h-72">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<motion.div
|
||||
key={'Emails'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
>
|
||||
<Form {...form}>
|
||||
<fieldset
|
||||
className="mt-2 flex flex-col gap-y-4 rounded-lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Sender</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === '-1' ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
loading={isLoadingEmails}
|
||||
className="bg-background"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{emails.map((email) => (
|
||||
<SelectItem key={email.id} value={email.id}>
|
||||
{email.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>Documenso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailReplyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply To Email</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} maxLength={254} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Subject</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} maxLength={255} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Message</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground p-4">
|
||||
<DocumentSendEmailMessageHelper />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="bg-background mt-2 h-16 resize-none"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</Form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{distributionMethod === DocumentDistributionMethod.NONE && (
|
||||
<motion.div
|
||||
key={'Links'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="min-h-60 rounded-lg border"
|
||||
>
|
||||
{envelope.status === DocumentStatus.DRAFT ? (
|
||||
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<Trans>
|
||||
We will generate signing links for you, which you can send to the
|
||||
recipients through your method of choice.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="text-muted-foreground divide-y">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{recipients.map((recipient) => (
|
||||
<li
|
||||
key={recipient.id}
|
||||
className="flex items-center justify-between px-4 py-3 text-sm"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{recipient.email}
|
||||
</p>
|
||||
}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{t(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
{recipient.role !== RecipientRole.CC && (
|
||||
<CopyTextButton
|
||||
value={formatSigningLink(recipient.token)}
|
||||
onCopySuccess={() => {
|
||||
toast({
|
||||
title: t`Copied to clipboard`,
|
||||
description: t`The signing link has been copied to your clipboard.`,
|
||||
});
|
||||
}}
|
||||
badgeContentUncopied={
|
||||
<p className="ml-1 text-xs">
|
||||
<Trans>Copy</Trans>
|
||||
</p>
|
||||
}
|
||||
badgeContentCopied={
|
||||
<p className="ml-1 text-xs">
|
||||
<Trans>Copied</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isSubmitting}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Send</Trans>
|
||||
) : (
|
||||
<Trans>Generate Links</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Some signers have not been assigned a signature field. Please assign at least 1
|
||||
signature field to each signer before proceeding.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
113
apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx
Normal file
113
apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type EnvelopeDuplicateDialogProps = {
|
||||
envelopeId: string;
|
||||
envelopeType: EnvelopeType;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeDuplicateDialog = ({
|
||||
envelopeId,
|
||||
envelopeType,
|
||||
trigger,
|
||||
}: EnvelopeDuplicateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpc.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ duplicatedEnvelopeId }) => {
|
||||
toast({
|
||||
title: t`Envelope Duplicated`,
|
||||
description: t`Your envelope has been successfully duplicated.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
const path =
|
||||
envelopeType === EnvelopeType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateEnvelope({ envelopeId });
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be duplicated at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
{envelopeType === EnvelopeType.DOCUMENT ? (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Document</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>This document will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>This template will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeItemDeleteDialogProps = {
|
||||
canItemBeDeleted: boolean;
|
||||
envelopeId: string;
|
||||
envelopeItemId: string;
|
||||
envelopeItemTitle: string;
|
||||
onDelete?: (envelopeItemId: string) => void;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeItemDeleteDialog = ({
|
||||
trigger,
|
||||
canItemBeDeleted,
|
||||
envelopeId,
|
||||
envelopeItemId,
|
||||
envelopeItemTitle,
|
||||
onDelete,
|
||||
}: EnvelopeItemDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteEnvelopeItem, isPending: isDeleting } =
|
||||
trpc.envelope.item.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`You have successfully removed this envelope item.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onDelete?.(envelopeItemId);
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to remove this envelope item. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
{canItemBeDeleted ? (
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to remove the following document and all associated fields
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription className="text-center font-semibold">
|
||||
{envelopeItemTitle}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<fieldset disabled={isDeleting}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={async () =>
|
||||
deleteEnvelopeItem({
|
||||
envelopeId,
|
||||
envelopeItemId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
) : (
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>This item cannot be deleted</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You cannot delete this item because the document has been sent to recipients
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
export type EnvelopeRedistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
};
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZEnvelopeRedistributeFormSchema = z.object({
|
||||
recipients: z.array(z.number()).min(1, {
|
||||
message: msg`You must select at least one item`.id,
|
||||
}),
|
||||
});
|
||||
|
||||
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
|
||||
|
||||
export const EnvelopeRedistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
}: EnvelopeRedistributeDialogProps) => {
|
||||
const recipients = envelope.recipients;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: redistributeEnvelope } = trpcReact.envelope.redistribute.useMutation();
|
||||
|
||||
const form = useForm<TEnvelopeRedistributeFormSchema>({
|
||||
defaultValues: {
|
||||
recipients: [],
|
||||
},
|
||||
resolver: zodResolver(ZEnvelopeRedistributeFormSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TEnvelopeRedistributeFormSchema) => {
|
||||
try {
|
||||
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
|
||||
|
||||
toast({
|
||||
title: t`Envelope resent`,
|
||||
description: t`Your envelope has been resent successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This envelope could not be resent at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Resend Document</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Send reminders to the following recipients</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipients"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients
|
||||
.filter((recipient) => recipient.signingStatus === SigningStatus.NOT_SIGNED)
|
||||
.map((recipient) => (
|
||||
<FormItem
|
||||
key={recipient.id}
|
||||
className="flex flex-row items-center justify-between gap-x-3 px-3"
|
||||
>
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
})}
|
||||
>
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
{recipient.email}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
checked
|
||||
? onChange([...value, recipient.id])
|
||||
: onChange(value.filter((v) => v !== recipient.id))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isSubmitting}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
<Trans>Send reminder</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
const onFormSubmit = async () => {
|
||||
try {
|
||||
await deleteFolder({
|
||||
id: folder.id,
|
||||
folderId: folder.id,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const form = useForm<TMoveFolderFormSchema>({
|
||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||
@ -63,12 +63,16 @@ export const FolderMoveDialog = ({
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||
if (!folder) return;
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await moveFolder({
|
||||
id: folder.id,
|
||||
parentId: targetFolderId || null,
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
parentId: targetFolderId || null,
|
||||
},
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@ -61,8 +61,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const isTeamContext = !!team;
|
||||
|
||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||
defaultValues: {
|
||||
@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
|
||||
try {
|
||||
await updateFolder({
|
||||
id: folder.id,
|
||||
name: data.name,
|
||||
visibility: isTeamContext
|
||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||
: DocumentVisibility.EVERYONE,
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
visibility: data.visibility,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -140,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
)}
|
||||
/>
|
||||
|
||||
{isTeamContext && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
||||
@ -4,14 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import type { Template, TemplateDirectLink } from '@prisma/client';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type Template } from '@documenso/prisma/types/template-legacy-schema';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
|
||||
@ -52,7 +52,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type ManagePublicTemplateDialogProps = {
|
||||
directTemplates: (Template & {
|
||||
directTemplates: (Omit<Template, 'templateDocumentDataId'> & {
|
||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||
})[];
|
||||
initialTemplateId?: number | null;
|
||||
|
||||
117
apps/remix/app/components/dialogs/sign-field-dropdown-dialog.tsx
Normal file
117
apps/remix/app/components/dialogs/sign-field-dropdown-dialog.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
const ZSignFieldDropdownFormSchema = z.object({
|
||||
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
|
||||
|
||||
export type SignFieldDropdownDialogProps = {
|
||||
fieldMeta: TDropdownFieldMeta;
|
||||
};
|
||||
|
||||
export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogProps, string | null>(
|
||||
({ call, fieldMeta }) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
||||
|
||||
const form = useForm<TSignFieldDropdownFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldDropdownFormSchema),
|
||||
defaultValues: {
|
||||
dropdown: fieldMeta.defaultValue,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Dropdown Field</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Select a value to sign into the field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dropdown"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue placeholder={t`Select an option`} />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{values.map((value, i) => (
|
||||
<SelectItem key={i} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,91 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldEmailFormSchema = z.object({
|
||||
email: z.string().min(1, { message: msg`Email is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||
|
||||
export type SignFieldEmailDialogProps = Record<string, never>;
|
||||
|
||||
export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, string | null>(
|
||||
({ call }) => {
|
||||
const form = useForm<TSignFieldEmailFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldEmailFormSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Email</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your email into the field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.email))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,97 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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';
|
||||
|
||||
const ZSignFieldInitialsFormSchema = z.object({
|
||||
initials: z.string().min(1, { message: msg`Initials are required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldInitialsFormSchema = z.infer<typeof ZSignFieldInitialsFormSchema>;
|
||||
|
||||
export type SignFieldInitialsDialogProps = {
|
||||
//
|
||||
};
|
||||
|
||||
export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogProps, string | null>(
|
||||
({ call }) => {
|
||||
const form = useForm<TSignFieldInitialsFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldInitialsFormSchema),
|
||||
defaultValues: {
|
||||
initials: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Initials</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your initials into the field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.initials))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="initials"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Initials</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
93
apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
Normal file
93
apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldNameFormSchema = z.object({
|
||||
name: z.string().min(1, { message: msg`Name is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldNameFormSchema = z.infer<typeof ZSignFieldNameFormSchema>;
|
||||
|
||||
export type SignFieldNameDialogProps = {
|
||||
//
|
||||
};
|
||||
|
||||
export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, string | null>(
|
||||
({ call }) => {
|
||||
const form = useForm<TSignFieldNameFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldNameFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Name</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your full name into the field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.name))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
144
apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
Normal file
144
apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TNumberFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
|
||||
let schema = z.coerce.number({
|
||||
invalid_type_error: msg`Please enter a valid number`.id, // Todo: Envelopes - Check that this works
|
||||
});
|
||||
|
||||
const { numberFormat, minValue, maxValue } = fieldMeta;
|
||||
|
||||
if (typeof minValue === 'number') {
|
||||
schema = schema.min(minValue);
|
||||
}
|
||||
|
||||
if (typeof maxValue === 'number') {
|
||||
schema = schema.max(maxValue);
|
||||
}
|
||||
|
||||
if (numberFormat) {
|
||||
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
||||
|
||||
if (!foundRegex) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
return schema.refine(
|
||||
(value) => {
|
||||
return foundRegex.test(value.toString());
|
||||
},
|
||||
{
|
||||
message: `Number needs to be formatted as ${numberFormat}`,
|
||||
// Todo: Envelopes
|
||||
// message: msg`Number needs to be formatted as ${numberFormat}`.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
export type SignFieldNumberDialogProps = {
|
||||
fieldMeta: TNumberFieldMeta;
|
||||
};
|
||||
|
||||
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
|
||||
({ call, fieldMeta }) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const ZSignFieldNumberFormSchema = z.object({
|
||||
number: createNumberFieldSchema(fieldMeta),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof ZSignFieldNumberFormSchema>>({
|
||||
resolver: zodResolver(ZSignFieldNumberFormSchema),
|
||||
defaultValues: {
|
||||
number: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Number Field</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Insert a value into the number field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.number))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="number"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
{fieldMeta.label && <FormLabel>{fieldMeta.label}</FormLabel>}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={fieldMeta.placeholder ?? t`Enter your number here`}
|
||||
className={cn('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':
|
||||
fieldState.error,
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||
|
||||
export type SignFieldSignatureDialogProps = {
|
||||
initialSignature?: string;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const SignFieldSignatureDialog = createCallable<
|
||||
SignFieldSignatureDialogProps,
|
||||
string | null
|
||||
>(
|
||||
({
|
||||
call,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
initialSignature,
|
||||
}) => {
|
||||
const [localSignature, setLocalSignature] = useState(initialSignature);
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Signature Field</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<SignaturePad
|
||||
value={localSignature ?? ''}
|
||||
onChange={({ value }) => setLocalSignature(value)}
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DocumentSigningDisclosure />
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!localSignature}
|
||||
onClick={() => call.end(localSignature || null)}
|
||||
>
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
120
apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
Normal file
120
apps/remix/app/components/dialogs/sign-field-text-dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TTextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
const ZSignFieldTextFormSchema = z.object({
|
||||
text: z.string().min(1, { message: msg`Text is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldTextFormSchema = z.infer<typeof ZSignFieldTextFormSchema>;
|
||||
|
||||
export type SignFieldTextDialogProps = {
|
||||
fieldMeta?: TTextFieldMeta;
|
||||
};
|
||||
|
||||
export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, string | null>(
|
||||
({ call, fieldMeta }) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TSignFieldTextFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldTextFormSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Text Field</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Insert a value into the text field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
{fieldMeta?.label && <FormLabel>{fieldMeta?.label}</FormLabel>}
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id="custom-text"
|
||||
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
|
||||
className={cn('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':
|
||||
fieldState.error,
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{fieldMeta?.characterLimit !== undefined &&
|
||||
fieldMeta?.characterLimit > 0 &&
|
||||
!fieldState.error && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Plural
|
||||
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
|
||||
one="# character remaining"
|
||||
other="# characters remaining"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -44,7 +44,9 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
||||
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
|
||||
if (isUploadingFile) {
|
||||
return;
|
||||
}
|
||||
@ -54,7 +56,7 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
||||
try {
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
const { id } = await createTemplate({
|
||||
const { legacyTemplateId: id } = await createTemplate({
|
||||
title: file.name,
|
||||
templateDocumentDataId: response.id,
|
||||
folderId: folderId,
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||
|
||||
export type TemplateDirectLinkDialogWrapperProps = {
|
||||
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkDialogWrapper = ({
|
||||
template,
|
||||
}: TemplateDirectLinkDialogWrapperProps) => {
|
||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-3"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setTemplateDirectLinkOpen(true);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
{template.directLink ? (
|
||||
<Trans>Manage Direct Link</Trans>
|
||||
) : (
|
||||
<Trans>Create Direct Link</Trans>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
template={template}
|
||||
open={isTemplateDirectLinkOpen}
|
||||
onOpenChange={setTemplateDirectLinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -3,13 +3,15 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||
import {
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
type Template,
|
||||
type TemplateDirectLink,
|
||||
} from '@prisma/client';
|
||||
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
ClipboardCopyIcon,
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
LoaderIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link, useRevalidator } from 'react-router';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
@ -31,6 +33,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
@ -47,20 +50,19 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateDirectLinkDialogProps = {
|
||||
template: Template & {
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
templateId: number;
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
||||
|
||||
export const TemplateDirectLinkDialog = ({
|
||||
template,
|
||||
open,
|
||||
onOpenChange,
|
||||
templateId,
|
||||
directLink,
|
||||
recipients,
|
||||
trigger,
|
||||
}: TemplateDirectLinkDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { quota, remaining } = useLimits();
|
||||
@ -69,8 +71,9 @@ export const TemplateDirectLinkDialog = ({
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
|
||||
const [token, setToken] = useState(template.directLink?.token ?? null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(directLink?.enabled ?? false);
|
||||
const [token, setToken] = useState(directLink?.token ?? null);
|
||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
|
||||
token ? 'MANAGE' : 'ONBOARD',
|
||||
@ -80,11 +83,11 @@ export const TemplateDirectLinkDialog = ({
|
||||
|
||||
const validDirectTemplateRecipients = useMemo(
|
||||
() =>
|
||||
template.recipients.filter(
|
||||
recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
),
|
||||
[template.recipients],
|
||||
[recipients],
|
||||
);
|
||||
|
||||
const {
|
||||
@ -140,7 +143,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
onSuccess: async () => {
|
||||
await revalidate();
|
||||
|
||||
onOpenChange(false);
|
||||
setOpen(false);
|
||||
setToken(null);
|
||||
|
||||
toast({
|
||||
@ -178,7 +181,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
setSelectedRecipientId(recipientId);
|
||||
|
||||
await createTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
templateId,
|
||||
directRecipientId: recipientId,
|
||||
});
|
||||
};
|
||||
@ -195,300 +198,311 @@ export const TemplateDirectLinkDialog = ({
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<fieldset disabled={isLoading} className="relative">
|
||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||
{match({ token, currentStep })
|
||||
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create Direct Signing Link</Trans>
|
||||
</DialogTitle>
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" className="px-3">
|
||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Here's how it works:</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{directLink ? <Trans>Manage Direct Link</Trans> : <Trans>Create Direct Link</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent hideClose>
|
||||
<fieldset disabled={isLoading} className="relative">
|
||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||
{match({ token, currentStep })
|
||||
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create Direct Signing Link</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<ul className="mt-4 space-y-4 pl-12">
|
||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||
<li className="relative" key={index}>
|
||||
<div className="absolute -left-12">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
||||
{index + 1}
|
||||
<DialogDescription>
|
||||
<Trans>Here's how it works:</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="mt-4 space-y-4 pl-12">
|
||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||
<li className="relative" key={index}>
|
||||
<div className="absolute -left-12">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold">{_(step.title)}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3 className="font-semibold">{_(step.title)}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{remaining.directTemplates === 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>
|
||||
Direct template link usage exceeded ({quota.directTemplates}/
|
||||
{quota.directTemplates})
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You have reached the maximum limit of {quota.directTemplates} direct
|
||||
templates.{' '}
|
||||
<Link
|
||||
className="mt-1 block underline underline-offset-4"
|
||||
to={`/o/${organisation.url}/settings/billing`}
|
||||
>
|
||||
Upgrade your account to continue!
|
||||
</Link>
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{remaining.directTemplates === 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>
|
||||
Direct template link usage exceeded ({quota.directTemplates}/
|
||||
{quota.directTemplates})
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
You have reached the maximum limit of {quota.directTemplates} direct
|
||||
templates.{' '}
|
||||
<Link
|
||||
className="mt-1 block underline underline-offset-4"
|
||||
to={`/o/${organisation.url}/settings/billing`}
|
||||
>
|
||||
Upgrade your account to continue!
|
||||
</Link>
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{remaining.directTemplates !== 0 && (
|
||||
<DialogFooter className="mx-auto mt-4">
|
||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||
<Trans> Enable direct link signing</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||
<DialogContent className="relative">
|
||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Choose Direct Link Recipient</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Choose an existing recipient from below to continue</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans>Recipient</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Role</Trans>
|
||||
</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validDirectTemplateRecipients.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-16 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>No valid recipients found</Trans>
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{validDirectTemplateRecipients.map((row) => (
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={row.id}
|
||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p>{row.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{selectedRecipientId === row.id ? (
|
||||
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
||||
) : (
|
||||
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||
{!template.recipients.some(
|
||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
) && (
|
||||
<DialogFooter className="mx-auto">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{validDirectTemplateRecipients.length !== 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Or</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||
onClick={async () =>
|
||||
createTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Create one automatically</Trans>
|
||||
{remaining.directTemplates !== 0 && (
|
||||
<DialogFooter className="mx-auto mt-4">
|
||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||
<Trans> Enable direct link signing</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||
<DialogContent className="relative">
|
||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Direct Link Signing</Trans>
|
||||
</DialogTitle>
|
||||
)}
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Manage the direct link signing for this template</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Choose Direct Link Recipient</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Label className="flex flex-row">
|
||||
<Trans>Enable Direct Link Signing</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<Trans>
|
||||
Disabling direct link signing will prevent anyone from accessing the
|
||||
link.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<DialogDescription>
|
||||
<Trans>Choose an existing recipient from below to continue</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Switch
|
||||
className="mt-2"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(value) => setIsEnabled(value)}
|
||||
/>
|
||||
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans>Recipient</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Role</Trans>
|
||||
</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validDirectTemplateRecipients.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-16 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>No valid recipients found</Trans>
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{validDirectTemplateRecipients.map((row) => (
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={row.id}
|
||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p>{row.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{selectedRecipientId === row.id ? (
|
||||
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
||||
) : (
|
||||
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="copy-direct-link">
|
||||
<Trans>Copy Shareable Link</Trans>
|
||||
</Label>
|
||||
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||
{!recipients.some(
|
||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
) && (
|
||||
<DialogFooter className="mx-auto">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{validDirectTemplateRecipients.length !== 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Or</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="copy-direct-link"
|
||||
disabled
|
||||
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
|
||||
readOnly
|
||||
className="pr-12"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
|
||||
<Button
|
||||
variant="none"
|
||||
type="button"
|
||||
className="h-8 w-8"
|
||||
onClick={() => void onCopyClick(token)}
|
||||
className="mt-2"
|
||||
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||
onClick={async () =>
|
||||
createTemplateDirectLink({
|
||||
templateId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<Trans>Create one automatically</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Direct Link Signing</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Manage the direct link signing for this template</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Label className="flex flex-row">
|
||||
<Trans>Enable Direct Link Signing</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<Trans>
|
||||
Disabling direct link signing will prevent anyone from accessing the
|
||||
link.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
|
||||
<Switch
|
||||
className="mt-2"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(value) => setIsEnabled(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="copy-direct-link">
|
||||
<Trans>Copy Shareable Link</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="copy-direct-link"
|
||||
disabled
|
||||
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
|
||||
readOnly
|
||||
className="pr-12"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
|
||||
<Button
|
||||
variant="none"
|
||||
type="button"
|
||||
className="h-8 w-8"
|
||||
onClick={() => void onCopyClick(token)}
|
||||
>
|
||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-full sm:w-auto"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-full sm:w-auto"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isTogglingTemplateAccess}
|
||||
onClick={async () => {
|
||||
await toggleTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
enabled: isEnabled,
|
||||
}).catch(() => null);
|
||||
<Button
|
||||
type="button"
|
||||
loading={isTogglingTemplateAccess}
|
||||
onClick={async () => {
|
||||
await toggleTemplateDirectLink({
|
||||
templateId,
|
||||
enabled: isEnabled,
|
||||
}).catch(() => null);
|
||||
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Please note that proceeding will remove direct linking recipient and turn it
|
||||
into a placeholder.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Please note that proceeding will remove direct linking recipient and turn it
|
||||
into a placeholder.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setCurrentStep('MANAGE')}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setCurrentStep('MANAGE')}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
|
||||
>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</fieldset>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => void deleteTemplateDirectLink({ templateId })}
|
||||
>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId ?? null,
|
||||
type: FolderType.TEMPLATE,
|
||||
@ -83,7 +83,7 @@ export function TemplateMoveToFolderDialog({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
||||
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@ -96,9 +96,11 @@ export function TemplateMoveToFolderDialog({
|
||||
|
||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||
try {
|
||||
await moveTemplateToFolder({
|
||||
await updateTemplate({
|
||||
templateId,
|
||||
folderId: data.folderId ?? null,
|
||||
data: {
|
||||
folderId: data.folderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
|
||||
@ -384,6 +384,7 @@ export function TemplateUseDialog({
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="useCustomDocument"
|
||||
>
|
||||
{/* Todo: Envelopes - How will this work? */}
|
||||
<Trans>Upload custom document</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||
import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||
|
||||
// Define the schema for configuration
|
||||
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
||||
|
||||
@ -118,6 +118,7 @@ export const ConfigureFieldsView = ({
|
||||
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
||||
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||
envelopeId: '',
|
||||
}));
|
||||
}, [configData.signers]);
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
@ -37,6 +37,7 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { EmbedClientLoading } from './embed-client-loading';
|
||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
@ -44,20 +45,22 @@ import { EmbedDocumentFields } from './embed-document-fields';
|
||||
|
||||
export type EmbedDirectTemplateClientPageProps = {
|
||||
token: string;
|
||||
envelopeId: string;
|
||||
updatedAt: Date;
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: DocumentMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhiteLabelling?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
token,
|
||||
envelopeId,
|
||||
updatedAt,
|
||||
documentData,
|
||||
recipient: _recipient,
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
@ -321,9 +324,13 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -33,7 +33,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: Pick<
|
||||
DocumentMeta | TemplateMeta,
|
||||
DocumentMeta,
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
|
||||
@ -3,7 +3,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
@ -15,12 +15,14 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import {
|
||||
type DocumentField,
|
||||
DocumentReadOnlyFields,
|
||||
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
@ -35,6 +37,7 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
||||
@ -46,11 +49,12 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
documentData: DocumentData;
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: DocumentMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
@ -60,6 +64,7 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
token,
|
||||
documentId,
|
||||
envelopeId,
|
||||
documentData,
|
||||
recipient,
|
||||
fields,
|
||||
@ -272,15 +277,17 @@ export const EmbedSignDocumentClientPage = ({
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
||||
|
||||
{allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog
|
||||
document={{ id: documentId }}
|
||||
documentId={documentId}
|
||||
token={token}
|
||||
onRejected={onDocumentRejected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
|
||||
@ -212,7 +212,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningRejectDialog
|
||||
document={document}
|
||||
documentId={document.id}
|
||||
token={token}
|
||||
onRejected={onRejected}
|
||||
/>
|
||||
|
||||
@ -17,12 +17,12 @@ import {
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import {
|
||||
type TDocumentMetaDateFormat,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
31
apps/remix/app/components/forms/editor/constants.ts
Normal file
31
apps/remix/app/components/forms/editor/constants.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// export const numberFormatValues = [
|
||||
// {
|
||||
// label: '123,456,789.00',
|
||||
// value: '123,456,789.00',
|
||||
// },
|
||||
// {
|
||||
// label: '123.456.789,00',
|
||||
// value: '123.456.789,00',
|
||||
// },
|
||||
// {
|
||||
// label: '123456,789.00',
|
||||
// value: '123456,789.00',
|
||||
// },
|
||||
// ];
|
||||
|
||||
export const checkboxValidationRules = ['Select at least', 'Select exactly', 'Select at most'];
|
||||
export const checkboxValidationLength = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
export const checkboxValidationSigns = [
|
||||
{
|
||||
label: 'Select at least',
|
||||
value: '>=',
|
||||
},
|
||||
{
|
||||
label: 'Select exactly',
|
||||
value: '=',
|
||||
},
|
||||
{
|
||||
label: 'Select at most',
|
||||
value: '<=',
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,293 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
||||
ZCheckboxFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { checkboxValidationLength, checkboxValidationRules } from './constants';
|
||||
import {
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZCheckboxFieldFormSchema = ZCheckboxFieldMeta.pick({
|
||||
label: true,
|
||||
direction: true,
|
||||
validationRule: true,
|
||||
validationLength: true,
|
||||
required: true,
|
||||
values: true,
|
||||
readOnly: true,
|
||||
})
|
||||
.extend({
|
||||
validationLength: z.coerce.number().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// You need to specify both validation rule and length together
|
||||
if (data.validationRule && !data.validationLength) {
|
||||
return false;
|
||||
}
|
||||
if (data.validationLength && !data.validationRule) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'You need to specify both the validation rule and the number of options',
|
||||
path: ['validationRule'],
|
||||
},
|
||||
);
|
||||
|
||||
type TCheckboxFieldFormSchema = z.infer<typeof ZCheckboxFieldFormSchema>;
|
||||
|
||||
type EditorFieldCheckboxFormProps = {
|
||||
value: CheckboxFieldMeta | undefined;
|
||||
onValueChange: (value: CheckboxFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldCheckboxForm = ({
|
||||
value = {
|
||||
type: 'checkbox',
|
||||
direction: 'vertical',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldCheckboxFormProps) => {
|
||||
const form = useForm<TCheckboxFieldFormSchema>({
|
||||
resolver: zodResolver(ZCheckboxFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
direction: value.direction || 'vertical',
|
||||
validationRule: value.validationRule || '',
|
||||
validationLength: value.validationLength || 0,
|
||||
values: value.values || [{ id: 1, checked: false, value: '' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
const addValue = () => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
const newId =
|
||||
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
|
||||
|
||||
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
if (currentValues.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues = [...currentValues];
|
||||
newValues.splice(index, 1);
|
||||
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZCheckboxFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
...value,
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="direction"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Direction</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Select direction`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="vertical">
|
||||
<Trans>Vertical</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="horizontal">
|
||||
<Trans>Horizontal</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center justify-start gap-x-4">
|
||||
<div className="flex w-2/3 flex-col">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validationRule"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Validation</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Select at least`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{checkboxValidationRules.map((item, index) => (
|
||||
<SelectItem key={index} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex w-1/3 flex-col">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validationLength"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? String(field.value) : ''}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
|
||||
<SelectValue placeholder={t`Pick a number`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{checkboxValidationLength.map((item, index) => (
|
||||
<SelectItem key={index} value={String(item)}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="-mx-4 mb-4 mt-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Checkbox values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={addValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{(formValues.values || []).map((value, index) => (
|
||||
<li key={`checkbox-value-${index}`} className="flex flex-row items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.checked`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input className="w-full" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TDateFieldMeta as DateFieldMeta,
|
||||
ZDateFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZDateFieldFormSchema = ZDateFieldMeta.pick({
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
});
|
||||
|
||||
type TDateFieldFormSchema = z.infer<typeof ZDateFieldFormSchema>;
|
||||
|
||||
type EditorFieldDateFormProps = {
|
||||
value: DateFieldMeta | undefined;
|
||||
onValueChange: (value: DateFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldDateForm = ({
|
||||
value = {
|
||||
type: 'date',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldDateFormProps) => {
|
||||
const form = useForm<TDateFieldFormSchema>({
|
||||
resolver: zodResolver(ZDateFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZDateFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'date',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,240 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type TDropdownFieldMeta as DropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZDropdownFieldFormSchema = z
|
||||
.object({
|
||||
defaultValue: z.string().optional(),
|
||||
values: z
|
||||
.object({
|
||||
value: z.string().min(1, {
|
||||
message: msg`Option value cannot be empty`.id,
|
||||
}),
|
||||
})
|
||||
.array()
|
||||
.min(1, {
|
||||
message: msg`Dropdown must have at least one option`.id,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Todo: Envelopes - This doesn't work.
|
||||
console.log({
|
||||
data,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const values = data.map((item) => item.value);
|
||||
return new Set(values).size === values.length;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Duplicate values are not allowed',
|
||||
},
|
||||
),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Default value must be one of the available options
|
||||
if (data.defaultValue && data.values) {
|
||||
return data.values.some((item) => item.value === data.defaultValue);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Default value must be one of the available options',
|
||||
path: ['defaultValue'],
|
||||
},
|
||||
);
|
||||
|
||||
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
|
||||
|
||||
type EditorFieldDropdownFormProps = {
|
||||
value: DropdownFieldMeta | undefined;
|
||||
onValueChange: (value: DropdownFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldDropdownForm = ({
|
||||
value = {
|
||||
type: 'dropdown',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldDropdownFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TDropdownFieldFormSchema>({
|
||||
resolver: zodResolver(ZDropdownFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
defaultValue: value.defaultValue,
|
||||
values: value.values || [{ value: 'Option 1' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = useWatch({
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const addValue = () => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
const newValues = [...currentValues, { value: 'New option' }];
|
||||
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
if (currentValues.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues = [...currentValues];
|
||||
newValues.splice(index, 1);
|
||||
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZDropdownFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'dropdown',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
const { formState } = form;
|
||||
|
||||
useEffect(() => {
|
||||
console.log({
|
||||
errors: formState.errors,
|
||||
formValues,
|
||||
});
|
||||
}, [formState, formState.errors, formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Select default option</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Default Value`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(formValues.values || []).map((item, index) => (
|
||||
<SelectItem key={index} value={item.value || ''}>
|
||||
{item.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-1">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="-mx-4 mb-4 mt-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Dropdown values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={addValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{(formValues.values || []).map((value, index) => (
|
||||
<li key={`dropdown-value-${index}`} className="flex flex-row gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TEmailFieldMeta as EmailFieldMeta,
|
||||
ZEmailFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZEmailFieldFormSchema = ZEmailFieldMeta.pick({
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
});
|
||||
|
||||
type TEmailFieldFormSchema = z.infer<typeof ZEmailFieldFormSchema>;
|
||||
|
||||
type EditorFieldEmailFormProps = {
|
||||
value: EmailFieldMeta | undefined;
|
||||
onValueChange: (value: EmailFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldEmailForm = ({
|
||||
value = {
|
||||
type: 'email',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldEmailFormProps) => {
|
||||
const form = useForm<TEmailFieldFormSchema>({
|
||||
resolver: zodResolver(ZEmailFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZEmailFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'email',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,222 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { type Control, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
// Can't seem to get the non-any type to work with correct types.
|
||||
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FormControlType = Control<any>;
|
||||
|
||||
export const EditorGenericFontSizeField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="fontSize"
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={8}
|
||||
max={96}
|
||||
className="bg-background"
|
||||
placeholder={t`Field font size`}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorGenericTextAlignField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="textAlign"
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>
|
||||
<Trans>Text Align</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select text align`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">
|
||||
<Trans>Left</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="center">
|
||||
<Trans>Center</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="right">
|
||||
<Trans>Right</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorGenericRequiredField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { watch, setValue } = useFormContext();
|
||||
|
||||
const readOnly = watch('readOnly');
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) {
|
||||
setValue('required', false);
|
||||
}
|
||||
}, [readOnly]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="required"
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn('flex items-center space-x-2', className)}>
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="field-required"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-required">
|
||||
<Trans>Required Field</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorGenericReadOnlyField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { watch, setValue } = useFormContext();
|
||||
|
||||
const required = watch('required');
|
||||
|
||||
useEffect(() => {
|
||||
if (required) {
|
||||
setValue('readOnly', false);
|
||||
}
|
||||
}, [required]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="readOnly"
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn('flex items-center space-x-2', className)}>
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="field-read-only"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-read-only">
|
||||
<Trans>Read Only</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorGenericLabelField = ({
|
||||
formControl,
|
||||
className,
|
||||
}: {
|
||||
formControl: FormControlType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>
|
||||
<Trans>Label</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field label`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TInitialsFieldMeta as InitialsFieldMeta,
|
||||
ZInitialsFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZInitialsFieldFormSchema = ZInitialsFieldMeta.pick({
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
});
|
||||
|
||||
type TInitialsFieldFormSchema = z.infer<typeof ZInitialsFieldFormSchema>;
|
||||
|
||||
type EditorFieldInitialsFormProps = {
|
||||
value: InitialsFieldMeta | undefined;
|
||||
onValueChange: (value: InitialsFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldInitialsForm = ({
|
||||
value = {
|
||||
type: 'initials',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldInitialsFormProps) => {
|
||||
const form = useForm<TInitialsFieldFormSchema>({
|
||||
resolver: zodResolver(ZInitialsFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZInitialsFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'initials',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
DEFAULT_FIELD_FONT_SIZE,
|
||||
type TNameFieldMeta as NameFieldMeta,
|
||||
ZNameFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZNameFieldFormSchema = ZNameFieldMeta.pick({
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
});
|
||||
|
||||
type TNameFieldFormSchema = z.infer<typeof ZNameFieldFormSchema>;
|
||||
|
||||
type EditorFieldNameFormProps = {
|
||||
value: NameFieldMeta | undefined;
|
||||
onValueChange: (value: NameFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldNameForm = ({
|
||||
value = {
|
||||
type: 'name',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldNameFormProps) => {
|
||||
const form = useForm<TNameFieldFormSchema>({
|
||||
resolver: zodResolver(ZNameFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: value.textAlign || 'left',
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZNameFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'name',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericFontSizeField formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,277 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
type TNumberFieldMeta as NumberFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericLabelField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
|
||||
label: true,
|
||||
placeholder: true,
|
||||
value: true,
|
||||
numberFormat: true,
|
||||
fontSize: true,
|
||||
textAlign: true,
|
||||
required: true,
|
||||
readOnly: true,
|
||||
minValue: true,
|
||||
maxValue: true,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Minimum value cannot be greater than maximum value
|
||||
if (typeof data.minValue === 'number' && typeof data.maxValue === 'number') {
|
||||
return data.minValue <= data.maxValue;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Minimum value cannot be greater than maximum value',
|
||||
path: ['minValue'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// A read-only field must have a value greater than 0
|
||||
if (data.readOnly && data.value !== undefined && data.value !== '') {
|
||||
const numberValue = parseFloat(data.value);
|
||||
return !isNaN(numberValue) && numberValue > 0;
|
||||
}
|
||||
return !data.readOnly || (data.value !== undefined && data.value !== '');
|
||||
},
|
||||
{
|
||||
message: 'A read-only field must have a value greater than 0',
|
||||
path: ['value'],
|
||||
},
|
||||
);
|
||||
|
||||
type TNumberFieldFormSchema = z.infer<typeof ZNumberFieldFormSchema>;
|
||||
|
||||
type EditorFieldNumberFormProps = {
|
||||
value: NumberFieldMeta | undefined;
|
||||
onValueChange: (value: NumberFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldNumberForm = ({
|
||||
value = {
|
||||
type: 'number',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldNumberFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TNumberFieldFormSchema>({
|
||||
resolver: zodResolver(ZNumberFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
placeholder: value.placeholder || '',
|
||||
value: value.value || '',
|
||||
numberFormat: value.numberFormat || null,
|
||||
fontSize: value.fontSize || 14,
|
||||
textAlign: value.textAlign || 'left',
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
minValue: value.minValue,
|
||||
maxValue: value.maxValue,
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'number',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<EditorGenericLabelField formControl={form.control} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="placeholder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Placeholder</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" placeholder={t`Placeholder`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Value</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" placeholder={t`Value`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="numberFormat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Number format</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Field format`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{numberFormatValues.map((item, index) => (
|
||||
<SelectItem key={index} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>None</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full flex-row gap-x-4">
|
||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
{/* Validation section */}
|
||||
<section className="space-y-2">
|
||||
<div className="-mx-4 mb-4 mt-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Validation</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-row gap-x-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="minValue"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Min</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-background"
|
||||
placeholder="E.g. 0"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value === '' ? null : e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxValue"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Max</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="bg-background"
|
||||
placeholder="E.g. 100"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value === '' ? null : e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,190 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import {
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZRadioFieldFormSchema = z
|
||||
.object({
|
||||
label: z.string().optional(),
|
||||
values: z
|
||||
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
|
||||
.array()
|
||||
.min(1)
|
||||
.optional(),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// There cannot be more than one checked option
|
||||
if (data.values) {
|
||||
const checkedValues = data.values.filter((option) => option.checked);
|
||||
return checkedValues.length <= 1;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'There cannot be more than one checked option',
|
||||
path: ['values'],
|
||||
},
|
||||
);
|
||||
|
||||
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||
|
||||
export type EditorFieldRadioFormProps = {
|
||||
value: RadioFieldMeta | undefined;
|
||||
onValueChange: (value: RadioFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldRadioForm = ({
|
||||
value = {
|
||||
type: 'radio',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldRadioFormProps) => {
|
||||
const form = useForm<TRadioFieldFormSchema>({
|
||||
resolver: zodResolver(ZRadioFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = useWatch({
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const addValue = () => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
const newId =
|
||||
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
|
||||
|
||||
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
if (currentValues.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues = [...currentValues];
|
||||
newValues.splice(index, 1);
|
||||
|
||||
form.setValue('values', newValues);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZRadioFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'radio',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2 pb-2">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="-mx-4 mb-4 mt-2">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
<Trans>Radio values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={addValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{(formValues.values || []).map((value, index) => (
|
||||
<li key={`radio-value-${index}`} className="flex flex-row items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.checked`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
// Uncheck all other values.
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
if (value) {
|
||||
const newValues = currentValues.map((val) => ({
|
||||
...val,
|
||||
checked: false,
|
||||
}));
|
||||
|
||||
form.setValue('values', newValues);
|
||||
}
|
||||
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`values.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input className="w-full" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,191 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import {
|
||||
EditorGenericFontSizeField,
|
||||
EditorGenericReadOnlyField,
|
||||
EditorGenericRequiredField,
|
||||
EditorGenericTextAlignField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZTextFieldFormSchema = z
|
||||
.object({
|
||||
label: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
characterLimit: z.coerce.number().min(0).optional(),
|
||||
fontSize: z.coerce.number().min(8).max(96).optional(),
|
||||
textAlign: z.enum(['left', 'center', 'right']).optional(),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// A read-only field must have text
|
||||
return !data.readOnly || (data.text && data.text.length > 0);
|
||||
},
|
||||
{
|
||||
message: 'A read-only field must have text',
|
||||
path: ['text'],
|
||||
},
|
||||
);
|
||||
|
||||
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
|
||||
|
||||
type EditorFieldTextFormProps = {
|
||||
value: TextFieldMeta | undefined;
|
||||
onValueChange: (value: TextFieldMeta) => void;
|
||||
};
|
||||
|
||||
export const EditorFieldTextForm = ({
|
||||
value = {
|
||||
type: 'text',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldTextFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TTextFieldFormSchema>({
|
||||
resolver: zodResolver(ZTextFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
placeholder: value.placeholder || '',
|
||||
text: value.text || '',
|
||||
characterLimit: value.characterLimit || 0,
|
||||
fontSize: value.fontSize || 14,
|
||||
textAlign: value.textAlign || 'left',
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
},
|
||||
});
|
||||
|
||||
const { control } = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
onValueChange({
|
||||
type: 'text',
|
||||
...validatedFormValues.data,
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Label</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field label`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="placeholder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Placeholder</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field placeholder`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Add text</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-auto"
|
||||
placeholder={t`Add text to the field`}
|
||||
{...field}
|
||||
rows={1}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="characterLimit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Character Limit</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="bg-background"
|
||||
placeholder={t`Field character limit`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full flex-row gap-x-4">
|
||||
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
|
||||
|
||||
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<EditorGenericRequiredField formControl={form.control} />
|
||||
</div>
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -70,6 +70,7 @@ export type SignInFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
oidcProviderLabel?: string;
|
||||
returnTo?: string;
|
||||
@ -79,6 +80,7 @@ export const SignInForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
@ -95,6 +97,8 @@ export const SignInForm = ({
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
const redirectPath = useMemo(() => {
|
||||
@ -271,6 +275,22 @@ export const SignInForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onSignInWithMicrosoftClick = async () => {
|
||||
try {
|
||||
await authClient.microsoft.signIn({
|
||||
redirectPath,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSignInWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn({
|
||||
@ -363,7 +383,7 @@ export const SignInForm = ({
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
|
||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
||||
{hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
@ -387,6 +407,20 @@ export const SignInForm = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithMicrosoftClick}
|
||||
>
|
||||
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
|
||||
Microsoft
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -66,6 +66,7 @@ export type SignUpFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
};
|
||||
|
||||
@ -73,6 +74,7 @@ export const SignUpForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
}: SignUpFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
@ -84,6 +86,8 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
@ -148,6 +152,20 @@ export const SignUpForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onSignUpWithMicrosoftClick = async () => {
|
||||
try {
|
||||
await authClient.microsoft.signIn();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSignUpWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn();
|
||||
@ -227,7 +245,7 @@ export const SignUpForm = ({
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
||||
hasSocialAuthEnabled && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
@ -302,7 +320,7 @@ export const SignUpForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
||||
{hasSocialAuthEnabled && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
@ -330,6 +348,26 @@ export const SignUpForm = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@ -60,7 +60,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
to={`${getRootHref(params, { returnEmptyRootString: true })}`}
|
||||
to={getRootHref(params)}
|
||||
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||
>
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
|
||||
@ -55,7 +55,7 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
|
||||
|
||||
export type DirectTemplateSigningFormProps = {
|
||||
flowStep: DocumentFlowStep;
|
||||
directRecipient: Recipient;
|
||||
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
|
||||
directRecipientFields: Field[];
|
||||
template: Omit<TTemplate, 'user'>;
|
||||
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
||||
@ -417,11 +417,11 @@ export const DirectTemplateSigningForm = ({
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit}
|
||||
onSignatureComplete={async () => handleSubmit()}
|
||||
documentTitle={template.title}
|
||||
fields={localFields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={directRecipient.role}
|
||||
recipient={directRecipient}
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
@ -0,0 +1,312 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
type FormStep = 'method-selection' | 'code-input';
|
||||
type TwoFactorMethod = 'email' | 'authenticator';
|
||||
|
||||
const ZAccessAuth2FAFormSchema = z.object({
|
||||
token: z.string().length(6, { message: 'Token must be 6 characters long' }),
|
||||
});
|
||||
|
||||
type TAccessAuth2FAFormSchema = z.infer<typeof ZAccessAuth2FAFormSchema>;
|
||||
|
||||
export type AccessAuth2FAFormProps = {
|
||||
onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void;
|
||||
token: string;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => {
|
||||
const [step, setStep] = useState<FormStep>('method-selection');
|
||||
const [selectedMethod, setSelectedMethod] = useState<TwoFactorMethod | null>(null);
|
||||
|
||||
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
|
||||
const [millisecondsRemaining, setMillisecondsRemaining] = useState<number | null>(null);
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { user } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } =
|
||||
trpc.document.accessAuth.request2FAEmail.useMutation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZAccessAuth2FAFormSchema),
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
});
|
||||
|
||||
const hasAuthenticatorEnabled = user?.twoFactorEnabled === true;
|
||||
|
||||
const onMethodSelect = async (method: TwoFactorMethod) => {
|
||||
setSelectedMethod(method);
|
||||
|
||||
if (method === 'email') {
|
||||
try {
|
||||
const result = await request2FAEmail({
|
||||
token: token,
|
||||
});
|
||||
|
||||
setExpiresAt(result.expiresAt);
|
||||
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||
|
||||
setStep('code-input');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStep('code-input');
|
||||
};
|
||||
|
||||
const onFormSubmit = (data: TAccessAuth2FAFormSchema) => {
|
||||
if (!selectedMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the auth options for the completion attempt
|
||||
const accessAuthOptions: TRecipientAccessAuth = {
|
||||
type: 'TWO_FACTOR_AUTH',
|
||||
token: data.token, // Just the user's code - backend will validate using method type
|
||||
method: selectedMethod,
|
||||
};
|
||||
|
||||
onSubmit(accessAuthOptions);
|
||||
};
|
||||
|
||||
const onGoBack = () => {
|
||||
setStep('method-selection');
|
||||
setSelectedMethod(null);
|
||||
setExpiresAt(null);
|
||||
setMillisecondsRemaining(null);
|
||||
};
|
||||
|
||||
const onResendEmail = async () => {
|
||||
if (selectedMethod !== 'email') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await request2FAEmail({
|
||||
token: token,
|
||||
});
|
||||
|
||||
setExpiresAt(result.expiresAt);
|
||||
setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now());
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (expiresAt) {
|
||||
setMillisecondsRemaining(expiresAt.valueOf() - Date.now());
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [expiresAt]);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
{step === 'method-selection' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Choose verification method</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Please select how you'd like to receive your verification code.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" padding="tight" className="text-sm">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||
onClick={async () => onMethodSelect('email')}
|
||||
disabled={isRequesting2FAEmail}
|
||||
>
|
||||
<MailIcon className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
<Trans>Email verification</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans>We'll send a 6-digit code to your email</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{hasAuthenticatorEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-auto w-full justify-start gap-3 p-4"
|
||||
onClick={async () => onMethodSelect('authenticator')}
|
||||
disabled={isRequesting2FAEmail}
|
||||
>
|
||||
<KeyIcon className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
<Trans>Authenticator app</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans>Use your authenticator app to generate a code</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'code-input' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onGoBack}>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>Enter verification code</Trans>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{selectedMethod === 'email' ? (
|
||||
<Trans>
|
||||
We've sent a 6-digit verification code to your email. Please enter it below to
|
||||
complete the document.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Please open your authenticator app and enter the 6-digit code for this document.
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="access-auth-2fa-form"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset disabled={isRequesting2FAEmail || form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 items-center justify-center">
|
||||
<PinInput
|
||||
{...field}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
pattern="^\d+$"
|
||||
aria-label="2FA code"
|
||||
containerClassName="h-12 justify-center"
|
||||
>
|
||||
<PinInputGroup>
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={0} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={1} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={2} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={3} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={4} />
|
||||
<PinInputSlot className="h-12 w-12 text-lg" index={5} />
|
||||
</PinInputGroup>
|
||||
</PinInput>
|
||||
|
||||
{expiresAt && millisecondsRemaining !== null && (
|
||||
<div
|
||||
className={cn('text-muted-foreground mt-2 text-center text-sm', {
|
||||
'text-destructive': millisecondsRemaining <= 0,
|
||||
})}
|
||||
>
|
||||
<Trans>
|
||||
Expires in{' '}
|
||||
{DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat(
|
||||
'mm:ss',
|
||||
)}
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Button
|
||||
type="submit"
|
||||
form="access-auth-2fa-form"
|
||||
className="w-full"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={isRequesting2FAEmail || form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Verify & Complete</Trans>
|
||||
</Button>
|
||||
|
||||
{selectedMethod === 'email' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onResendEmail}
|
||||
loading={isRequesting2FAEmail}
|
||||
>
|
||||
<Trans>Resend code</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,79 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ExternalLink, PaperclipIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
export type DocumentSigningAttachmentsPopoverProps = {
|
||||
envelopeId: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const DocumentSigningAttachmentsPopover = ({
|
||||
envelopeId,
|
||||
token,
|
||||
}: DocumentSigningAttachmentsPopoverProps) => {
|
||||
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||
envelopeId,
|
||||
token,
|
||||
});
|
||||
|
||||
if (!attachments || attachments.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<PaperclipIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Attachments</Trans>{' '}
|
||||
{attachments && attachments.data.length > 0 && (
|
||||
<span className="ml-1">({attachments.data.length})</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-96" align="start">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
<Trans>Attachments</Trans>
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Documents and resources related to this envelope.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{attachments?.data.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.data}
|
||||
title={attachment.data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2.5">
|
||||
<div className="bg-muted rounded p-2">
|
||||
<PaperclipIcon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
|
||||
{attachment.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client';
|
||||
import { type Envelope, FieldType, type Passkey, type Recipient } from '@prisma/client';
|
||||
|
||||
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||
@ -24,14 +24,16 @@ type PasskeyData = {
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
|
||||
|
||||
export type DocumentSigningAuthContextValue = {
|
||||
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
documentAuthOption: TDocumentAuthOptions;
|
||||
setDocumentAuthOptions: (_value: Document['authOptions']) => void;
|
||||
recipient: Recipient;
|
||||
setDocumentAuthOptions: (_value: Envelope['authOptions']) => void;
|
||||
recipient: SigningAuthRecipient;
|
||||
recipientAuthOption: TRecipientAuthOptions;
|
||||
setRecipient: (_value: Recipient) => void;
|
||||
setRecipient: (_value: SigningAuthRecipient) => void;
|
||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||
isAuthRedirectRequired: boolean;
|
||||
@ -61,8 +63,8 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
||||
};
|
||||
|
||||
export interface DocumentSigningAuthProviderProps {
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
recipient: Recipient;
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: SigningAuthRecipient;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@ -2,12 +2,17 @@ import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field } from '@prisma/client';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
type TRecipientAccessAuth,
|
||||
ZDocumentAccessAuthSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -27,15 +32,21 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningCompleteDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
documentTitle: string;
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
||||
role: RecipientRole;
|
||||
onSignatureComplete: (
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
) => void | Promise<void>;
|
||||
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||
disabled?: boolean;
|
||||
allowDictateNextSigner?: boolean;
|
||||
defaultNextSigner?: {
|
||||
@ -47,6 +58,7 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
|
||||
});
|
||||
|
||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
@ -57,7 +69,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
role,
|
||||
recipient,
|
||||
disabled = false,
|
||||
allowDictateNextSigner = false,
|
||||
defaultNextSigner,
|
||||
@ -65,6 +77,11 @@ export const DocumentSigningCompleteDialog = ({
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||
|
||||
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
|
||||
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
||||
|
||||
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const form = useForm<TNextSignerFormSchema>({
|
||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||
defaultValues: {
|
||||
@ -75,6 +92,11 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||
|
||||
const completionRequires2FA = useMemo(
|
||||
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
|
||||
[derivedRecipientAccessAuth],
|
||||
);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (form.formState.isSubmitting || !isComplete) {
|
||||
return;
|
||||
@ -93,16 +115,43 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||
try {
|
||||
if (allowDictateNextSigner && data.name && data.email) {
|
||||
await onSignatureComplete({ name: data.name, email: data.email });
|
||||
} else {
|
||||
await onSignatureComplete();
|
||||
// Check if 2FA is required
|
||||
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||
setShowTwoFactorForm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSigner =
|
||||
allowDictateNextSigner && data.name && data.email
|
||||
? { name: data.name, email: data.email }
|
||||
: undefined;
|
||||
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions);
|
||||
} catch (error) {
|
||||
console.error('Error completing signature:', error);
|
||||
const err = AppError.parseError(error);
|
||||
|
||||
if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) {
|
||||
// This was a 2FA validation failure - show the 2FA dialog again with error
|
||||
form.setValue('accessAuthOptions', undefined);
|
||||
|
||||
setTwoFactorValidationError('Invalid verification code. Please try again.');
|
||||
setShowTwoFactorForm(true);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => {
|
||||
form.setValue('accessAuthOptions', validatedAuthOptions);
|
||||
|
||||
setShowTwoFactorForm(false);
|
||||
setTwoFactorValidationError(null);
|
||||
|
||||
// Now trigger the form submission with auth options
|
||||
void form.handleSubmit(onFormSubmit)();
|
||||
};
|
||||
|
||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||
|
||||
return (
|
||||
@ -116,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
loading={isSubmitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
{match({ isComplete, role })
|
||||
{match({ isComplete, role: recipient.role })
|
||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||
@ -128,184 +177,194 @@ export const DocumentSigningCompleteDialog = ({
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
<DialogTitle>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allowDictateNextSigner && (
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||
>
|
||||
<Trans>Update Recipient</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingNextSigner && (
|
||||
<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" />
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!isComplete || !isNextSignerValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
{!showTwoFactorForm && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
<DialogTitle>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<DocumentSigningDisclosure className="mt-4" />
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!isComplete || !isNextSignerValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{showTwoFactorForm && (
|
||||
<AccessAuth2FAForm
|
||||
token={recipient.token}
|
||||
error={twoFactorValidationError}
|
||||
onSubmit={onTwoFactorFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -8,7 +8,7 @@ import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
@ -34,10 +34,10 @@ export type DocumentSigningFormProps = {
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
completeDocument: (
|
||||
authOptions?: TRecipientActionAuth,
|
||||
nextSigner?: { email: string; name: string },
|
||||
) => Promise<void>;
|
||||
completeDocument: (options: {
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
nextSigner?: { email: string; name: string };
|
||||
}) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
fieldsValidated: () => void;
|
||||
nextRecipient?: RecipientWithFields;
|
||||
@ -105,7 +105,7 @@ export const DocumentSigningForm = ({
|
||||
setIsAssistantSubmitting(true);
|
||||
|
||||
try {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
await completeDocument({ nextSigner });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
@ -149,10 +149,10 @@ export const DocumentSigningForm = ({
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={localFieldsValidated}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||
completeDocument({ nextSigner, accessAuthOptions })
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
@ -309,10 +309,13 @@ export const DocumentSigningForm = ({
|
||||
fields={fields}
|
||||
fieldsValidated={localFieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||
completeDocument({
|
||||
accessAuthOptions,
|
||||
nextSigner,
|
||||
})
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
@ -32,6 +32,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
||||
@ -46,10 +47,11 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||
|
||||
export type DocumentSigningPageViewProps = {
|
||||
export type DocumentSigningPageViewV1Props = {
|
||||
recipient: RecipientWithFields;
|
||||
document: DocumentAndSender;
|
||||
fields: Field[];
|
||||
@ -59,7 +61,7 @@ export type DocumentSigningPageViewProps = {
|
||||
includeSenderDetails: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningPageView = ({
|
||||
export const DocumentSigningPageViewV1 = ({
|
||||
recipient,
|
||||
document,
|
||||
fields,
|
||||
@ -67,9 +69,15 @@ export const DocumentSigningPageView = ({
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
includeSenderDetails,
|
||||
}: DocumentSigningPageViewProps) => {
|
||||
}: DocumentSigningPageViewV1Props) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const hasAuthenticator = authUser?.twoFactorEnabled
|
||||
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||
: false;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
@ -94,14 +102,16 @@ export const DocumentSigningPageView = ({
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
};
|
||||
|
||||
const completeDocument = async (
|
||||
authOptions?: TRecipientActionAuth,
|
||||
nextSigner?: { email: string; name: string },
|
||||
) => {
|
||||
const completeDocument = async (options: {
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
nextSigner?: { email: string; name: string };
|
||||
}) => {
|
||||
const { accessAuthOptions, nextSigner } = options;
|
||||
|
||||
const payload = {
|
||||
token: recipient.token,
|
||||
documentId: document.id,
|
||||
authOptions,
|
||||
accessAuthOptions,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
};
|
||||
|
||||
@ -222,7 +232,13 @@ export const DocumentSigningPageView = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={document.envelopeId}
|
||||
token={recipient.token}
|
||||
/>
|
||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||
@ -265,10 +281,10 @@ export const DocumentSigningPageView = ({
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
onSignatureComplete={async (nextSigner) =>
|
||||
completeDocument({ nextSigner })
|
||||
}
|
||||
recipient={recipient}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { SignFieldDropdownDialog } from '~/components/dialogs/sign-field-dropdown-dialog';
|
||||
import { SignFieldEmailDialog } from '~/components/dialogs/sign-field-email-dialog';
|
||||
import { SignFieldInitialsDialog } from '~/components/dialogs/sign-field-initials-dialog';
|
||||
import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog';
|
||||
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
|
||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
|
||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
|
||||
const EnvelopeSignerPageRenderer = lazy(
|
||||
async () => import('../envelope-signing/envelope-signer-page-renderer'),
|
||||
);
|
||||
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
recipient,
|
||||
recipientFields,
|
||||
recipientFieldsRemaining,
|
||||
showPendingFieldTooltip,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-gray-50">
|
||||
<SignFieldEmailDialog.Root />
|
||||
<SignFieldTextDialog.Root />
|
||||
<SignFieldNumberDialog.Root />
|
||||
<SignFieldNameDialog.Root />
|
||||
<SignFieldInitialsDialog.Root />
|
||||
<SignFieldDropdownDialog.Root />
|
||||
<SignFieldSignatureDialog.Root />
|
||||
|
||||
<EnvelopeSignerHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-73px)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4 lg:flex">
|
||||
<div className="px-4">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
|
||||
<Trans>Sign Document</Trans>
|
||||
|
||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
||||
<Trans>{recipientFieldsRemaining.length} fields remaining</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="bg-muted relative my-4 h-[4px] rounded-md">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
style={{
|
||||
width: `${(100 / recipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<EnvelopeSignerForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<div className="space-y-3 px-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
<div className="w-full">
|
||||
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</Button>
|
||||
|
||||
{/* Todo: Envelopes */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:text-destructive w-full justify-start"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer of left sidebar. */}
|
||||
<div className="mt-auto px-4">
|
||||
<Button asChild variant="ghost" className="w-full justify-start">
|
||||
<Link to="/">
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Changes based on current step */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
<div className="flex h-fit space-x-2 overflow-x-auto p-4">
|
||||
{envelopeItems.map((doc, i) => (
|
||||
<EnvelopeItemSelector
|
||||
key={doc.id}
|
||||
number={i + 1}
|
||||
primaryText={doc.title}
|
||||
secondaryText={
|
||||
<Plural
|
||||
one="1 Field"
|
||||
other="# Fields"
|
||||
value={
|
||||
recipientFieldsRemaining.filter((field) => field.envelopeItemId === doc.id)
|
||||
.length
|
||||
}
|
||||
/>
|
||||
}
|
||||
isSelected={currentEnvelopeItem?.id === doc.id}
|
||||
buttonProps={{ onClick: () => setCurrentEnvelopeItem(doc.id) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex justify-center p-4">
|
||||
{currentEnvelopeItem &&
|
||||
showPendingFieldTooltip &&
|
||||
recipientFieldsRemaining.length > 0 &&
|
||||
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && (
|
||||
<FieldToolTip
|
||||
key={recipientFieldsRemaining[0].id}
|
||||
field={recipientFieldsRemaining[0]}
|
||||
color="warning"
|
||||
>
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
|
||||
{currentEnvelopeItem ? (
|
||||
<PDFViewerKonvaLazy
|
||||
key={currentEnvelopeItem.id}
|
||||
documentDataId={currentEnvelopeItem.documentDataId}
|
||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-foreground text-sm">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -10,7 +10,10 @@ export interface DocumentSigningRecipientContextValue {
|
||||
* In regular mode, this is the actual signer.
|
||||
* In assistant mode, this is the recipient who is helping fill out the document.
|
||||
*/
|
||||
recipient: Recipient | RecipientWithFields;
|
||||
recipient: Pick<
|
||||
Recipient | RecipientWithFields,
|
||||
'name' | 'email' | 'token' | 'role' | 'authOptions'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Only present in assistant mode.
|
||||
@ -29,7 +32,10 @@ const DocumentSigningRecipientContext = createContext<DocumentSigningRecipientCo
|
||||
);
|
||||
|
||||
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
|
||||
recipient: Recipient | RecipientWithFields;
|
||||
recipient: Pick<
|
||||
Recipient | RecipientWithFields,
|
||||
'name' | 'email' | 'token' | 'role' | 'authOptions'
|
||||
>;
|
||||
targetSigner?: RecipientWithFields | null;
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
@ -37,13 +36,13 @@ const ZRejectDocumentFormSchema = z.object({
|
||||
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||
|
||||
export interface DocumentSigningRejectDialogProps {
|
||||
document: Pick<Document, 'id'>;
|
||||
documentId: number;
|
||||
token: string;
|
||||
onRejected?: (reason: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function DocumentSigningRejectDialog({
|
||||
document,
|
||||
documentId,
|
||||
token,
|
||||
onRejected,
|
||||
}: DocumentSigningRejectDialogProps) {
|
||||
@ -66,7 +65,7 @@ export function DocumentSigningRejectDialog({
|
||||
const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => {
|
||||
try {
|
||||
await rejectDocumentWithToken({
|
||||
documentId: document.id,
|
||||
documentId,
|
||||
token,
|
||||
reason,
|
||||
});
|
||||
|
||||
@ -0,0 +1,290 @@
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||
|
||||
export type EnvelopeSigningContextValue = {
|
||||
fullName: string;
|
||||
setFullName: (_value: string) => void;
|
||||
email: string;
|
||||
setEmail: (_value: string) => void;
|
||||
signature: string | null;
|
||||
setSignature: (_value: string | null) => void;
|
||||
|
||||
showPendingFieldTooltip: boolean;
|
||||
setShowPendingFieldTooltip: (_value: boolean) => void;
|
||||
|
||||
envelopeData: EnvelopeForSigningResponse;
|
||||
envelope: EnvelopeForSigningResponse['envelope'];
|
||||
|
||||
recipient: EnvelopeForSigningResponse['recipient'];
|
||||
recipientFieldsRemaining: Field[];
|
||||
recipientFields: Field[];
|
||||
selectedRecipientFields: Field[];
|
||||
nextRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
||||
otherRecipientCompletedFields: (Field & {
|
||||
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus' | 'role'>;
|
||||
})[];
|
||||
assistantRecipients: EnvelopeForSigningResponse['envelope']['recipients'];
|
||||
assistantFields: Field[];
|
||||
setSelectedAssistantRecipientId: (_value: number | null) => void;
|
||||
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
||||
|
||||
signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
|
||||
};
|
||||
|
||||
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
||||
|
||||
export const useEnvelopeSigningContext = () => {
|
||||
return useContext(EnvelopeSigningContext);
|
||||
};
|
||||
|
||||
export const useRequiredEnvelopeSigningContext = () => {
|
||||
const context = useEnvelopeSigningContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Signing context is required');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export interface EnvelopeSigningProviderProps {
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
signature?: string | null;
|
||||
envelopeData: EnvelopeForSigningResponse;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvelopeSigningProvider = ({
|
||||
fullName: initialFullName,
|
||||
email: initialEmail,
|
||||
signature: initialSignature,
|
||||
envelopeData: initialEnvelopeData,
|
||||
children,
|
||||
}: EnvelopeSigningProviderProps) => {
|
||||
const [envelopeData, setEnvelopeData] = useState(initialEnvelopeData);
|
||||
|
||||
const { envelope, recipient } = envelopeData;
|
||||
|
||||
const [fullName, setFullName] = useState(initialFullName || '');
|
||||
const [email, setEmail] = useState(initialEmail || '');
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocument,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const { mutateAsync: signEnvelopeField } = trpc.envelope.field.sign.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (data) => {
|
||||
console.log('signEnvelopeField', data);
|
||||
|
||||
const newRecipientFields = envelopeData.recipient.fields.map((field) =>
|
||||
field.id === data.signedField.id ? data.signedField : field,
|
||||
);
|
||||
|
||||
setEnvelopeData((prev) => ({
|
||||
...prev,
|
||||
recipient: {
|
||||
...prev.recipient,
|
||||
fields: newRecipientFields,
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure the user signature doesn't show up if it's not allowed.
|
||||
const [signature, setSignature] = useState(
|
||||
(() => {
|
||||
const sig = initialSignature || '';
|
||||
const isBase64 = isBase64Image(sig);
|
||||
|
||||
if (
|
||||
!sig &&
|
||||
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||
envelope.documentMeta.drawSignatureEnabled) &&
|
||||
envelopeData.recipientSignature?.signatureImageAsBase64
|
||||
) {
|
||||
return envelopeData.recipientSignature.signatureImageAsBase64;
|
||||
}
|
||||
|
||||
if (
|
||||
!sig &&
|
||||
envelope.documentMeta.typedSignatureEnabled &&
|
||||
envelopeData.recipientSignature?.typedSignature
|
||||
) {
|
||||
return envelopeData.recipientSignature.typedSignature;
|
||||
}
|
||||
|
||||
if (
|
||||
isBase64 &&
|
||||
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)
|
||||
) {
|
||||
return sig;
|
||||
}
|
||||
|
||||
if (!isBase64 && envelope.documentMeta.typedSignatureEnabled) {
|
||||
return sig;
|
||||
}
|
||||
|
||||
return null;
|
||||
})(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Assistant recipients are those that have a signing order after the assistant.
|
||||
*/
|
||||
const assistantRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0))
|
||||
: [];
|
||||
|
||||
/**
|
||||
* Assistant fields are those fulfill all of the following:
|
||||
* - From recipients that have not signed
|
||||
* - After the assistant signing order
|
||||
* - Are not signature fields
|
||||
*/
|
||||
const assistantFields =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? assistantRecipients
|
||||
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
|
||||
.map((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
|
||||
.flat()
|
||||
: [];
|
||||
|
||||
/**
|
||||
* The recipient that the assistant has currently selected to sign on behalf of.
|
||||
*/
|
||||
const [selectedAssistantRecipientId, setSelectedAssistantRecipientId] = useState<number | null>(
|
||||
assistantRecipients[0]?.id || null,
|
||||
);
|
||||
|
||||
const selectedAssistantRecipient = useMemo(() => {
|
||||
return envelope.recipients.find((r) => r.id === selectedAssistantRecipientId) || null;
|
||||
}, [envelope.recipients, selectedAssistantRecipientId]);
|
||||
|
||||
/**
|
||||
* The fields that are still required to be signed by the current recipient.
|
||||
*/
|
||||
const recipientFieldsRemaining = useMemo(() => {
|
||||
return envelopeData.recipient.fields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||
}, [envelopeData.recipient.fields]);
|
||||
|
||||
/**
|
||||
* All the fields for the current recipient.
|
||||
*/
|
||||
const recipientFields = useMemo(() => {
|
||||
return envelopeData.recipient.fields;
|
||||
}, [envelopeData.recipient.fields]);
|
||||
|
||||
const selectedRecipientFields = useMemo(() => {
|
||||
return recipientFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
||||
}, [recipientFields, selectedAssistantRecipient]);
|
||||
|
||||
/**
|
||||
* Fields that have been completed by other recipients.
|
||||
*/
|
||||
const otherRecipientCompletedFields = envelope.recipients
|
||||
.filter(({ signingStatus }) => signingStatus === SigningStatus.SIGNED)
|
||||
.flatMap((recipient) =>
|
||||
recipient.fields.map((field) => ({
|
||||
...field,
|
||||
recipient: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
signingStatus: recipient.signingStatus,
|
||||
role: recipient.role,
|
||||
},
|
||||
})),
|
||||
)
|
||||
.filter((field) => field.inserted);
|
||||
|
||||
const nextRecipient = useMemo(() => {
|
||||
if (
|
||||
!envelope.documentMeta.signingOrder ||
|
||||
envelope.documentMeta.signingOrder !== 'SEQUENTIAL'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sortedRecipients = envelope.recipients.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]
|
||||
: null;
|
||||
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
||||
|
||||
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
|
||||
console.log('insertField', fieldId, fieldValue);
|
||||
|
||||
await signEnvelopeField({
|
||||
token: envelopeData.recipient.token,
|
||||
fieldId,
|
||||
fieldValue,
|
||||
authOptions: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeSigningContext.Provider
|
||||
value={{
|
||||
fullName,
|
||||
setFullName,
|
||||
email,
|
||||
setEmail,
|
||||
signature,
|
||||
setSignature,
|
||||
envelopeData,
|
||||
envelope,
|
||||
|
||||
showPendingFieldTooltip,
|
||||
setShowPendingFieldTooltip,
|
||||
|
||||
recipient,
|
||||
recipientFieldsRemaining,
|
||||
recipientFields,
|
||||
nextRecipient,
|
||||
|
||||
otherRecipientCompletedFields,
|
||||
assistantRecipients,
|
||||
assistantFields,
|
||||
setSelectedAssistantRecipientId,
|
||||
selectedAssistantRecipient,
|
||||
selectedRecipientFields,
|
||||
|
||||
signField,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EnvelopeSigningContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
EnvelopeSigningProvider.displayName = 'EnvelopeSigningProvider';
|
||||
@ -0,0 +1,241 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Paperclip, Plus, X } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentAttachmentsPopoverProps = {
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
const ZAttachmentFormSchema = z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
url: z.string().url('Must be a valid URL'),
|
||||
});
|
||||
|
||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||
|
||||
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
const { mutateAsync: createAttachment, isPending: isCreating } =
|
||||
trpc.envelope.attachment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<TAttachmentFormSchema>({
|
||||
resolver: zodResolver(ZAttachmentFormSchema),
|
||||
defaultValues: {
|
||||
label: '',
|
||||
url: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TAttachmentFormSchema) => {
|
||||
try {
|
||||
await createAttachment({
|
||||
envelopeId,
|
||||
data: {
|
||||
label: data.label,
|
||||
data: data.url,
|
||||
},
|
||||
});
|
||||
|
||||
form.reset();
|
||||
|
||||
setIsAdding(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Attachment added successfully.`),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteAttachment = async (id: string) => {
|
||||
try {
|
||||
await deleteAttachment({ id });
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Attachment removed successfully.`),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
|
||||
<span>
|
||||
<Trans>Attachments</Trans>
|
||||
{attachments && attachments.data.length > 0 && (
|
||||
<span className="ml-1">({attachments.data.length})</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-96" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
<Trans>Attachments</Trans>
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Add links to relevant documents or resources.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{attachments && attachments.data.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{attachments?.data.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="border-border flex items-center justify-between rounded-md border p-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
||||
<a
|
||||
href={attachment.data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
|
||||
>
|
||||
{attachment.data}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void onDeleteAttachment(attachment.id)}
|
||||
className="ml-2 h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Trans>Add Attachment</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAdding && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={_(msg`Label`)} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type="url" placeholder={_(msg`URL`)} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -16,13 +19,16 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
|
||||
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
||||
|
||||
export type DocumentCertificateQRViewProps = {
|
||||
documentId: number;
|
||||
title: string;
|
||||
documentData: DocumentData;
|
||||
password?: string | null;
|
||||
internalVersion: number;
|
||||
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[];
|
||||
documentTeamUrl: string;
|
||||
recipientCount?: number;
|
||||
completedDate?: Date;
|
||||
};
|
||||
@ -30,31 +36,32 @@ export type DocumentCertificateQRViewProps = {
|
||||
export const DocumentCertificateQRView = ({
|
||||
documentId,
|
||||
title,
|
||||
documentData,
|
||||
password,
|
||||
internalVersion,
|
||||
envelopeItems,
|
||||
documentTeamUrl,
|
||||
recipientCount = 0,
|
||||
completedDate,
|
||||
}: DocumentCertificateQRViewProps) => {
|
||||
const { data: documentUrl } = trpc.shareLink.getDocumentInternalUrlForQRCode.useQuery({
|
||||
const { data: documentViaUser } = trpc.document.get.useQuery({
|
||||
documentId,
|
||||
});
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentUrl);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser);
|
||||
|
||||
const formattedDate = completedDate
|
||||
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (documentUrl) {
|
||||
if (documentViaUser) {
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
}, [documentUrl]);
|
||||
}, [documentViaUser]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-md">
|
||||
{/* Dialog for internal document link */}
|
||||
{documentUrl && (
|
||||
{documentViaUser && (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@ -72,7 +79,11 @@ export const DocumentCertificateQRView = ({
|
||||
|
||||
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||
<Button asChild>
|
||||
<a href={documentUrl} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={`${formatDocumentsPath(documentTeamUrl)}/${documentViaUser.envelopeId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans>Go to document</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
@ -95,11 +106,21 @@ export const DocumentCertificateQRView = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareDocumentDownloadButton title={title} documentData={documentData} />
|
||||
<ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 w-full">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} password={password} />
|
||||
{internalVersion === 2 ? (
|
||||
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
|
||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
</EnvelopeRenderProvider>
|
||||
) : (
|
||||
<>
|
||||
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -64,7 +64,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
const { id } = await createDocument({
|
||||
const { legacyDocumentId: id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||
|
||||
@ -83,7 +83,7 @@ export const DocumentEditForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
||||
const { mutateAsync: addFields } = trpc.field.setFieldsForDocument.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: ({ fields: newFields }) => {
|
||||
utils.document.get.setData(
|
||||
@ -230,6 +230,7 @@ export const DocumentEditForm = ({
|
||||
documentId: document.id,
|
||||
recipients: data.signers.map((signer) => ({
|
||||
...signer,
|
||||
id: signer.nativeId,
|
||||
// Explicitly set to null to indicate we want to remove auth if required.
|
||||
actionAuth: signer.actionAuth ?? [],
|
||||
})),
|
||||
@ -253,6 +254,7 @@ export const DocumentEditForm = ({
|
||||
documentId: document.id,
|
||||
recipients: data.signers.map((signer) => ({
|
||||
...signer,
|
||||
id: signer.nativeId,
|
||||
// Explicitly set to null to indicate we want to remove auth if required.
|
||||
actionAuth: signer.actionAuth ?? [],
|
||||
})),
|
||||
@ -292,7 +294,11 @@ export const DocumentEditForm = ({
|
||||
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
||||
return addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
fields: data.fields.map((field) => ({
|
||||
...field,
|
||||
id: field.nativeId,
|
||||
envelopeItemId: document.documentData.envelopeItemId,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
@ -388,7 +394,7 @@ export const DocumentEditForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
await navigate(`${documentRootPath}/${document.id}`);
|
||||
await navigate(`${documentRootPath}/${document.envelopeId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
@ -9,57 +8,43 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentPageViewButtonProps = {
|
||||
document: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
envelope: TEnvelope;
|
||||
};
|
||||
|
||||
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
|
||||
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
|
||||
const { user } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const recipient = document.recipients.find((recipient) => recipient.email === user.email);
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
const isRecipient = !!recipient;
|
||||
const isPending = document.status === DocumentStatus.PENDING;
|
||||
const isComplete = isDocumentCompleted(document);
|
||||
const isPending = envelope.status === DocumentStatus.PENDING;
|
||||
const isComplete = isDocumentCompleted(envelope);
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const role = recipient?.role;
|
||||
|
||||
const documentsPath = formatDocumentsPath(document.team.url);
|
||||
const formatPath = `${documentsPath}/${document.id}/edit`;
|
||||
const documentsPath = formatDocumentsPath(envelope.team.url);
|
||||
const formatPath = `${documentsPath}/${envelope.id}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.get.query(
|
||||
{
|
||||
documentId: document.id,
|
||||
},
|
||||
{
|
||||
context: {
|
||||
teamId: document.team?.id?.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
// Todo; Envelopes - Support multiple items
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
const documentData = documentWithData?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
if (!envelopeItem.documentData) {
|
||||
throw new Error('No document available');
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: documentWithData.title });
|
||||
await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
|
||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import {
|
||||
Copy,
|
||||
@ -19,7 +18,9 @@ import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
@ -39,14 +40,10 @@ import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/d
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentPageViewDropdownProps = {
|
||||
document: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
envelope: TEnvelope;
|
||||
};
|
||||
|
||||
export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownProps) => {
|
||||
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
|
||||
const { user } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
@ -57,14 +54,14 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
|
||||
const recipient = document.recipients.find((recipient) => recipient.email === user.email);
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
const isOwner = document.user.id === user.id;
|
||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||
const isPending = document.status === DocumentStatus.PENDING;
|
||||
const isDeleted = document.deletedAt !== null;
|
||||
const isComplete = isDocumentCompleted(document);
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
const isOwner = envelope.userId === user.id;
|
||||
const isDraft = envelope.status === DocumentStatus.DRAFT;
|
||||
const isPending = envelope.status === DocumentStatus.PENDING;
|
||||
const isDeleted = envelope.deletedAt !== null;
|
||||
const isComplete = isDocumentCompleted(envelope);
|
||||
const isCurrentTeamDocument = team && envelope.teamId === team.id;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
@ -73,7 +70,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.get.query(
|
||||
{
|
||||
documentId: document.id,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
},
|
||||
{
|
||||
context: {
|
||||
@ -88,7 +85,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: document.title });
|
||||
await downloadPDF({ documentData, fileName: envelope.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
@ -102,7 +99,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.get.query(
|
||||
{
|
||||
documentId: document.id,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
},
|
||||
{
|
||||
context: {
|
||||
@ -117,7 +114,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: document.title, version: 'original' });
|
||||
await downloadPDF({ documentData, fileName: envelope.title, version: 'original' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
@ -127,7 +124,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
}
|
||||
};
|
||||
|
||||
const nonSignedRecipients = document.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@ -142,7 +139,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
|
||||
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`${documentsPath}/${document.id}/edit`}>
|
||||
<Link to={`${documentsPath}/${envelope.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
@ -162,7 +159,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`${documentsPath}/${document.id}/logs`}>
|
||||
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Audit Logs</Trans>
|
||||
</Link>
|
||||
@ -184,7 +181,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
|
||||
{canManageDocument && (
|
||||
<DocumentRecipientLinkCopyDialog
|
||||
recipients={document.recipients}
|
||||
recipients={envelope.recipients}
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={!isPending || isDeleted}
|
||||
@ -197,10 +194,16 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentResendDialog document={document} recipients={nonSignedRecipients} />
|
||||
<DocumentResendDialog
|
||||
document={{
|
||||
...envelope,
|
||||
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
}}
|
||||
recipients={nonSignedRecipients}
|
||||
/>
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={isOwner ? undefined : recipient?.token}
|
||||
trigger={({ loading, disabled }) => (
|
||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||
@ -214,9 +217,9 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
</DropdownMenuContent>
|
||||
|
||||
<DocumentDeleteDialog
|
||||
id={document.id}
|
||||
status={document.status}
|
||||
documentTitle={document.title}
|
||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
status={envelope.status}
|
||||
documentTitle={envelope.title}
|
||||
open={isDeleteDialogOpen}
|
||||
canManageDocument={canManageDocument}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
@ -227,7 +230,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DocumentDuplicateDialog
|
||||
id={document.id}
|
||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
@ -3,21 +3,18 @@ import { useMemo } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document, Recipient, User } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
|
||||
export type DocumentPageViewInformationProps = {
|
||||
userId: number;
|
||||
document: Document & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
envelope: TEnvelope;
|
||||
};
|
||||
|
||||
export const DocumentPageViewInformation = ({
|
||||
document,
|
||||
envelope,
|
||||
userId,
|
||||
}: DocumentPageViewInformationProps) => {
|
||||
const isMounted = useIsMounted();
|
||||
@ -29,23 +26,23 @@ export const DocumentPageViewInformation = ({
|
||||
{
|
||||
description: msg`Uploaded by`,
|
||||
value:
|
||||
userId === document.userId ? _(msg`You`) : (document.user.name ?? document.user.email),
|
||||
userId === envelope.userId ? _(msg`You`) : (envelope.user.name ?? envelope.user.email),
|
||||
},
|
||||
{
|
||||
description: msg`Created`,
|
||||
value: DateTime.fromJSDate(document.createdAt)
|
||||
value: DateTime.fromJSDate(envelope.createdAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toFormat('MMMM d, yyyy'),
|
||||
},
|
||||
{
|
||||
description: msg`Last modified`,
|
||||
value: DateTime.fromJSDate(document.updatedAt)
|
||||
value: DateTime.fromJSDate(envelope.updatedAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toRelative(),
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMounted, document, userId]);
|
||||
}, [isMounted, envelope, userId]);
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||
|
||||
@ -2,7 +2,6 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import type { Document, Recipient } from '@prisma/client';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckIcon,
|
||||
@ -17,6 +16,7 @@ import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
@ -27,20 +27,18 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentPageViewRecipientsProps = {
|
||||
document: Document & {
|
||||
recipients: Recipient[];
|
||||
};
|
||||
envelope: TEnvelope;
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
export const DocumentPageViewRecipients = ({
|
||||
document,
|
||||
envelope,
|
||||
documentRootPath,
|
||||
}: DocumentPageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const recipients = document.recipients;
|
||||
const recipients = envelope.recipients;
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
@ -49,9 +47,9 @@ export const DocumentPageViewRecipients = ({
|
||||
<Trans>Recipients</Trans>
|
||||
</h1>
|
||||
|
||||
{!isDocumentCompleted(document.status) && (
|
||||
{!isDocumentCompleted(envelope.status) && (
|
||||
<Link
|
||||
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||
to={`${documentRootPath}/${envelope.id}/edit?step=signers`}
|
||||
title={_(msg`Modify recipients`)}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
@ -84,7 +82,7 @@ export const DocumentPageViewRecipients = ({
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||
<Badge variant="default">
|
||||
{match(recipient.role)
|
||||
@ -95,7 +93,7 @@ export const DocumentPageViewRecipients = ({
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.CC, () =>
|
||||
document.status === DocumentStatus.COMPLETED ? (
|
||||
envelope.status === DocumentStatus.COMPLETED ? (
|
||||
<>
|
||||
<MailIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Sent</Trans>
|
||||
@ -130,7 +128,7 @@ export const DocumentPageViewRecipients = ({
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
@ -138,7 +136,7 @@ export const DocumentPageViewRecipients = ({
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||
<PopoverHover
|
||||
trigger={
|
||||
@ -158,7 +156,7 @@ export const DocumentPageViewRecipients = ({
|
||||
</PopoverHover>
|
||||
)}
|
||||
|
||||
{document.status === DocumentStatus.PENDING &&
|
||||
{envelope.status === DocumentStatus.PENDING &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC && (
|
||||
<CopyTextButton
|
||||
|
||||
@ -28,11 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentUploadDropzoneProps = {
|
||||
export type DocumentUploadButtonProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProps) => {
|
||||
export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
@ -75,10 +75,10 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
const { id } = await createDocument({
|
||||
const { legacyDocumentId: id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||
timezone: userTimezone,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
@ -140,7 +140,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
loading={isLoading}
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDrop={async (files) => onFileDrop(files[0])}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
</div>
|
||||
@ -0,0 +1,198 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type EnvelopeUploadButtonProps = {
|
||||
className?: string;
|
||||
type: EnvelopeType;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload an envelope
|
||||
*/
|
||||
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const userTimezone = TIME_ZONES.find(
|
||||
(timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
);
|
||||
|
||||
const { quota, remaining, refreshLimits } = useLimits();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (organisation.subscription && remaining.documents === 0) {
|
||||
return msg`Document upload disabled due to unpaid invoices`;
|
||||
}
|
||||
|
||||
if (remaining.documents === 0) {
|
||||
return msg`You have reached your document limit.`;
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
return msg`Verify your email to upload documents.`;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remaining.documents, user.emailVerified, team]);
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error('Failed to upload document');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const envelopeItemsToCreate = result.filter(
|
||||
(item): item is { title: string; documentDataId: string } => item !== undefined,
|
||||
);
|
||||
|
||||
const { id } = await createEnvelope({
|
||||
folderId,
|
||||
type,
|
||||
title: files[0].name,
|
||||
items: envelopeItemsToCreate,
|
||||
meta: {
|
||||
timezone: userTimezone,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
void refreshLimits();
|
||||
|
||||
const pathPrefix =
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
|
||||
toast({
|
||||
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||
description:
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? t`Your document has been uploaded successfully.`
|
||||
: t`Your template has been uploaded successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title:
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? t`Your document failed to upload.`
|
||||
: t`Your template failed to upload.`,
|
||||
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DocumentDropzone
|
||||
loading={isLoading}
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
type="envelope"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
{type === EnvelopeType.DOCUMENT &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<TooltipContent>
|
||||
<p className="text-sm">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,316 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import {
|
||||
CalendarIcon,
|
||||
CheckSquareIcon,
|
||||
ContactIcon,
|
||||
DiscIcon,
|
||||
HashIcon,
|
||||
ListIcon,
|
||||
MailIcon,
|
||||
TextIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
|
||||
const MIN_HEIGHT_PX = 12;
|
||||
const MIN_WIDTH_PX = 36;
|
||||
|
||||
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
|
||||
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||
|
||||
export const fieldButtonList = [
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
icon: SignatureIcon,
|
||||
name: msg`Signature`,
|
||||
className: 'font-signature text-lg',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
icon: MailIcon,
|
||||
name: msg`Email`,
|
||||
},
|
||||
{
|
||||
type: FieldType.NAME,
|
||||
icon: UserIcon,
|
||||
name: msg`Name`,
|
||||
},
|
||||
{
|
||||
type: FieldType.INITIALS,
|
||||
icon: ContactIcon,
|
||||
name: msg`Initials`,
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
icon: CalendarIcon,
|
||||
name: msg`Date`,
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
icon: TextIcon,
|
||||
name: msg`Text`,
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
icon: HashIcon,
|
||||
name: msg`Number`,
|
||||
},
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
icon: DiscIcon,
|
||||
name: msg`Radio`,
|
||||
},
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
icon: CheckSquareIcon,
|
||||
name: msg`Checkbox`,
|
||||
},
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
icon: ListIcon,
|
||||
name: msg`Dropdown`,
|
||||
},
|
||||
];
|
||||
|
||||
type EnvelopeEditorFieldDragDropProps = {
|
||||
selectedRecipientId: number | null;
|
||||
selectedEnvelopeItemId: string | null;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorFieldDragDrop = ({
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
}: EnvelopeEditorFieldDragDropProps) => {
|
||||
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
|
||||
const { isWithinPageBounds, getPage } = useDocumentElement();
|
||||
|
||||
const isFieldsDisabled = useMemo(() => {
|
||||
const selectedSigner = envelope.recipients.find(
|
||||
(recipient) => recipient.id === selectedRecipientId,
|
||||
);
|
||||
const fields = envelope.fields;
|
||||
|
||||
if (!selectedSigner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow fields to be modified for templates regardless of anything.
|
||||
if (isTemplate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !canRecipientFieldsBeModified(selectedSigner, fields);
|
||||
}, [selectedRecipientId, envelope.recipients, envelope.fields]);
|
||||
|
||||
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const fieldBounds = useRef({
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
setIsFieldWithinBounds(
|
||||
isWithinPageBounds(
|
||||
event,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
fieldBounds.current.width,
|
||||
fieldBounds.current.height,
|
||||
),
|
||||
);
|
||||
|
||||
setCoords({
|
||||
x: event.clientX - fieldBounds.current.width / 2,
|
||||
y: event.clientY - fieldBounds.current.height / 2,
|
||||
});
|
||||
},
|
||||
[isWithinPageBounds],
|
||||
);
|
||||
|
||||
const onMouseClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!selectedField || !selectedRecipientId || !selectedEnvelopeItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (
|
||||
!$page ||
|
||||
!isWithinPageBounds(
|
||||
event,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
fieldBounds.current.width,
|
||||
fieldBounds.current.height,
|
||||
)
|
||||
) {
|
||||
setSelectedField(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
console.log({
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
width,
|
||||
rawPageX: event.pageX,
|
||||
rawPageY: event.pageY,
|
||||
});
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
let pageX = ((event.pageX - left) / width) * 100;
|
||||
let pageY = ((event.pageY - top) / height) * 100;
|
||||
|
||||
// Get the bounds as a percentage of the page width and height
|
||||
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||
|
||||
// And center it based on the bounds
|
||||
pageX -= fieldPageWidth / 2;
|
||||
pageY -= fieldPageHeight / 2;
|
||||
|
||||
const field = {
|
||||
formId: nanoid(12),
|
||||
envelopeItemId: selectedEnvelopeItemId,
|
||||
type: selectedField,
|
||||
page: pageNumber,
|
||||
positionX: pageX,
|
||||
positionY: pageY,
|
||||
width: fieldPageWidth,
|
||||
height: fieldPageHeight,
|
||||
recipientId: selectedRecipientId,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[selectedField]),
|
||||
};
|
||||
|
||||
editorFields.addField(field);
|
||||
|
||||
setIsFieldWithinBounds(false);
|
||||
setSelectedField(null);
|
||||
},
|
||||
[
|
||||
isWithinPageBounds,
|
||||
selectedField,
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
getPage,
|
||||
editorFields,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((_mutations) => {
|
||||
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
fieldBounds.current = {
|
||||
height: Math.max(DEFAULT_HEIGHT_PX),
|
||||
width: Math.max(DEFAULT_WIDTH_PX),
|
||||
};
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedField) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseClick);
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||
{fieldButtonList.map((field) => (
|
||||
<button
|
||||
disabled={isFieldsDisabled}
|
||||
key={field.type}
|
||||
type="button"
|
||||
onClick={() => setSelectedField(field.type)}
|
||||
onMouseDown={() => setSelectedField(field.type)}
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
field.className,
|
||||
)}
|
||||
>
|
||||
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
||||
{t(field.name)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||
// selectedSignerStyles?.base,
|
||||
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
'dark:text-black/60': isFieldWithinBounds,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: fieldBounds.current.height,
|
||||
width: fieldBounds.current.width,
|
||||
}}
|
||||
>
|
||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
||||
{t(FRIENDLY_FIELD_TYPE[selectedField])}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,686 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import type { FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import type { Layer } from 'konva/lib/Layer';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
import { usePageContext } from 'react-pdf';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
MIN_FIELD_HEIGHT_PX,
|
||||
MIN_FIELD_WIDTH_PX,
|
||||
convertPixelToPercentage,
|
||||
} from '@documenso/lib/universal/field-renderer/field-renderer';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
|
||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
|
||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const pageContext = usePageContext();
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { _className, page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const { t } = useLingui();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Layer | null>(null);
|
||||
const interactiveTransformer = useRef<Transformer | null>(null);
|
||||
|
||||
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
||||
|
||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||
|
||||
const viewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
editorFields.localFields.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[editorFields.localFields, pageContext.pageNumber],
|
||||
);
|
||||
|
||||
// Custom renderer from Konva examples.
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport,
|
||||
};
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
createPageCanvas(container);
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, viewport],
|
||||
);
|
||||
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
console.log('Field resized or moved');
|
||||
|
||||
const { current: container } = canvasElement;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDragEvent = event.type === 'dragend';
|
||||
|
||||
const fieldGroup = event.target as Konva.Group;
|
||||
const fieldFormId = fieldGroup.id();
|
||||
|
||||
const {
|
||||
width: fieldPixelWidth,
|
||||
height: fieldPixelHeight,
|
||||
x: fieldX,
|
||||
y: fieldY,
|
||||
} = fieldGroup.getClientRect({
|
||||
skipStroke: true,
|
||||
skipShadow: true,
|
||||
});
|
||||
|
||||
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
const positionPercentX = (fieldX / pageWidth) * 100;
|
||||
const positionPercentY = (fieldY / pageHeight) * 100;
|
||||
|
||||
// Get the bounds as a percentage of the page width and height
|
||||
const fieldPageWidth = (fieldPixelWidth / pageWidth) * 100;
|
||||
const fieldPageHeight = (fieldPixelHeight / pageHeight) * 100;
|
||||
|
||||
const fieldUpdates: Partial<TLocalField> = {
|
||||
positionX: positionPercentX,
|
||||
positionY: positionPercentY,
|
||||
};
|
||||
|
||||
// Do not update the width/height unless the field has actually been resized.
|
||||
// This is because our calculations will shift the width/height slightly
|
||||
// due to the way we convert between pixel and percentage.
|
||||
if (!isDragEvent) {
|
||||
fieldUpdates.width = fieldPageWidth;
|
||||
fieldUpdates.height = fieldPageHeight;
|
||||
}
|
||||
|
||||
// Todo: envelopes Use id
|
||||
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
|
||||
|
||||
// Select the field if it is not already selected.
|
||||
if (isDragEvent && interactiveTransformer.current?.nodes().length === 0) {
|
||||
setSelectedFields([fieldGroup]);
|
||||
}
|
||||
|
||||
pageLayer.current?.batchDraw();
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (field: TLocalField) => {
|
||||
if (!pageLayer.current || !interactiveTransformer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = envelope.recipients.find((r) => r.id === field.recipientId);
|
||||
const isFieldEditable =
|
||||
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||
|
||||
const { fieldGroup, isFirstRender } = renderField({
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.formId,
|
||||
...field,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
color: getRecipientColorKey(field.recipientId),
|
||||
editable: isFieldEditable,
|
||||
mode: 'edit',
|
||||
});
|
||||
|
||||
if (!isFieldEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
fieldGroup.off('click');
|
||||
fieldGroup.off('transformend');
|
||||
fieldGroup.off('dragend');
|
||||
|
||||
// Set up field selection.
|
||||
fieldGroup.on('click', () => {
|
||||
removePendingField();
|
||||
setSelectedFields([fieldGroup]);
|
||||
pageLayer.current?.batchDraw();
|
||||
});
|
||||
|
||||
fieldGroup.on('transformend', handleResizeOrMove);
|
||||
fieldGroup.on('dragend', handleResizeOrMove);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
stage.current?.add(pageLayer.current);
|
||||
|
||||
// Initialize snap guides layer
|
||||
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
||||
|
||||
// Add transformer for resizing and rotating.
|
||||
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
|
||||
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
|
||||
// Handle stage click to deselect.
|
||||
stage.current?.on('click', (e) => {
|
||||
removePendingField();
|
||||
|
||||
if (e.target === stage.current) {
|
||||
setSelectedFields([]);
|
||||
pageLayer.current?.batchDraw();
|
||||
}
|
||||
});
|
||||
|
||||
// When an item is dragged, select it automatically.
|
||||
const onDragStartOrEnd = (e: KonvaEventObject<Event>) => {
|
||||
removePendingField();
|
||||
|
||||
if (!e.target.hasName('field-group')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFieldChanging(e.type === 'dragstart');
|
||||
|
||||
const itemAlreadySelected = (interactiveTransformer.current?.nodes() || []).includes(
|
||||
e.target,
|
||||
);
|
||||
|
||||
// Do nothing and allow the transformer to handle it.
|
||||
// Required so when multiple items are selected, this won't deselect them.
|
||||
if (itemAlreadySelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFields([e.target]);
|
||||
};
|
||||
|
||||
stage.current?.on('dragstart', onDragStartOrEnd);
|
||||
stage.current?.on('dragend', onDragStartOrEnd);
|
||||
stage.current?.on('transformstart', () => setIsFieldChanging(true));
|
||||
stage.current?.on('transformend', () => setIsFieldChanging(false));
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an interactive transformer for the fields.
|
||||
*
|
||||
* Allows:
|
||||
* - Resizing
|
||||
* - Moving
|
||||
* - Selecting multiple fields
|
||||
* - Selecting empty area to create fields
|
||||
*/
|
||||
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
|
||||
const transformer = new Konva.Transformer({
|
||||
rotateEnabled: false,
|
||||
keepRatio: false,
|
||||
shouldOverdrawWholeArea: true,
|
||||
ignoreStroke: true,
|
||||
flipEnabled: false,
|
||||
boundBoxFunc: (oldBox, newBox) => {
|
||||
// Enforce minimum size
|
||||
if (newBox.width < 30 || newBox.height < 20) {
|
||||
return oldBox;
|
||||
}
|
||||
|
||||
return newBox;
|
||||
},
|
||||
});
|
||||
|
||||
layer.add(transformer);
|
||||
|
||||
// Add selection rectangle.
|
||||
const selectionRectangle = new Konva.Rect({
|
||||
fill: 'rgba(24, 160, 251, 0.3)',
|
||||
visible: false,
|
||||
});
|
||||
layer.add(selectionRectangle);
|
||||
|
||||
let x1: number;
|
||||
let y1: number;
|
||||
let x2: number;
|
||||
let y2: number;
|
||||
|
||||
stage.on('mousedown touchstart', (e) => {
|
||||
// do nothing if we mousedown on any shape
|
||||
if (e.target !== stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerPosition = stage.getPointerPosition();
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
x1 = pointerPosition.x;
|
||||
y1 = pointerPosition.y;
|
||||
x2 = pointerPosition.x;
|
||||
y2 = pointerPosition.y;
|
||||
|
||||
selectionRectangle.setAttrs({
|
||||
x: x1,
|
||||
y: y1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
visible: true,
|
||||
});
|
||||
});
|
||||
|
||||
stage.on('mousemove touchmove', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionRectangle.moveToTop();
|
||||
|
||||
const pointerPosition = stage.getPointerPosition();
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
x2 = pointerPosition.x;
|
||||
y2 = pointerPosition.y;
|
||||
|
||||
selectionRectangle.setAttrs({
|
||||
x: Math.min(x1, x2),
|
||||
y: Math.min(y1, y2),
|
||||
width: Math.abs(x2 - x1),
|
||||
height: Math.abs(y2 - y1),
|
||||
});
|
||||
});
|
||||
|
||||
stage.on('mouseup touchend', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update visibility in timeout, so we can check it in click event
|
||||
setTimeout(() => {
|
||||
selectionRectangle.visible(false);
|
||||
});
|
||||
|
||||
const stageFieldGroups = stage.find('.field-group') || [];
|
||||
const box = selectionRectangle.getClientRect();
|
||||
const selectedFieldGroups = stageFieldGroups.filter(
|
||||
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
||||
);
|
||||
setSelectedFields(selectedFieldGroups);
|
||||
|
||||
// Create a field if no items are selected or the size is too small.
|
||||
if (
|
||||
selectedFieldGroups.length === 0 &&
|
||||
canvasElement.current &&
|
||||
box.width > MIN_FIELD_WIDTH_PX &&
|
||||
box.height > MIN_FIELD_HEIGHT_PX &&
|
||||
editorFields.selectedRecipient &&
|
||||
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
||||
) {
|
||||
const pendingFieldCreation = new Konva.Rect({
|
||||
name: 'pending-field-creation',
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
fill: 'rgba(24, 160, 251, 0.3)',
|
||||
});
|
||||
|
||||
layer.add(pendingFieldCreation);
|
||||
setPendingFieldCreation(pendingFieldCreation);
|
||||
}
|
||||
});
|
||||
|
||||
// Clicks should select/deselect shapes
|
||||
stage.on('click tap', function (e) {
|
||||
// if we are selecting with rect, do nothing
|
||||
if (
|
||||
selectionRectangle.visible() &&
|
||||
selectionRectangle.width() > 0 &&
|
||||
selectionRectangle.height() > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If empty area clicked, remove all selections
|
||||
if (e.target === stage) {
|
||||
setSelectedFields([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do nothing if field not clicked, or if field is not editable
|
||||
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// do we pressed shift or ctrl?
|
||||
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
|
||||
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
|
||||
|
||||
if (!metaPressed && !isSelected) {
|
||||
// if no key pressed and the node is not selected
|
||||
// select just one
|
||||
setSelectedFields([e.target]);
|
||||
} else if (metaPressed && isSelected) {
|
||||
// if we pressed keys and node was selected
|
||||
// we need to remove it from selection:
|
||||
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
|
||||
// remove node from array
|
||||
nodes.splice(nodes.indexOf(e.target), 1);
|
||||
setSelectedFields(nodes);
|
||||
} else if (metaPressed && !isSelected) {
|
||||
// add the node into selection
|
||||
const nodes = transformer.nodes().concat([e.target]);
|
||||
setSelectedFields(nodes);
|
||||
}
|
||||
});
|
||||
|
||||
return transformer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render fields when they are added or removed from the localFields.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!pageLayer.current || !stage.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If doesn't exist in localFields, destroy it since it's been deleted.
|
||||
pageLayer.current.find('Group').forEach((group) => {
|
||||
if (
|
||||
group.name() === 'field-group' &&
|
||||
!localPageFields.some((field) => field.formId === group.id())
|
||||
) {
|
||||
console.log('Field removed, removing from canvas');
|
||||
group.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// If it exists, rerender.
|
||||
localPageFields.forEach((field) => {
|
||||
console.log('Field created/updated, rendering on canvas');
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
// If it doesn't exist, render it.
|
||||
//
|
||||
|
||||
// Rerender the transformer
|
||||
interactiveTransformer.current?.forceUpdate();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields]);
|
||||
|
||||
const setSelectedFields = (nodes: Konva.Node[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const fieldGroups = nodes.filter((node) => node.hasName('field-group')) as Konva.Group[];
|
||||
|
||||
interactiveTransformer.current?.nodes(fieldGroups);
|
||||
setSelectedKonvaFieldGroups(fieldGroups);
|
||||
|
||||
if (fieldGroups.length === 0 || fieldGroups.length > 1) {
|
||||
editorFields.setSelectedField(null);
|
||||
}
|
||||
|
||||
// Handle single field selection.
|
||||
if (fieldGroups.length === 1) {
|
||||
const fieldGroup = fieldGroups[0];
|
||||
|
||||
editorFields.setSelectedField(fieldGroup.id());
|
||||
fieldGroup.moveToTop();
|
||||
}
|
||||
};
|
||||
|
||||
const deletedSelectedFields = () => {
|
||||
const fieldFormids = selectedKonvaFieldGroups
|
||||
.map((field) => field.id())
|
||||
.filter((field) => field !== undefined);
|
||||
|
||||
editorFields.removeFieldsByFormId(fieldFormids);
|
||||
|
||||
setSelectedFields([]);
|
||||
};
|
||||
|
||||
const duplicatedSelectedFields = () => {
|
||||
const fields = selectedKonvaFieldGroups
|
||||
.map((field) => editorFields.getFieldByFormId(field.id()))
|
||||
.filter((field) => field !== undefined);
|
||||
|
||||
for (const field of fields) {
|
||||
editorFields.duplicateField(field);
|
||||
}
|
||||
};
|
||||
|
||||
const duplicatedSelectedFieldsOnAllPages = () => {
|
||||
const fields = selectedKonvaFieldGroups
|
||||
.map((field) => editorFields.getFieldByFormId(field.id()))
|
||||
.filter((field) => field !== undefined);
|
||||
|
||||
for (const field of fields) {
|
||||
editorFields.duplicateFieldToAllPages(field);
|
||||
}
|
||||
|
||||
setSelectedFields([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a field from a pending field.
|
||||
*/
|
||||
const createFieldFromPendingTemplate = (pendingFieldCreation: Konva.Rect, type: FieldType) => {
|
||||
const pixelWidth = pendingFieldCreation.width();
|
||||
const pixelHeight = pendingFieldCreation.height();
|
||||
const pixelX = pendingFieldCreation.x();
|
||||
const pixelY = pendingFieldCreation.y();
|
||||
|
||||
removePendingField();
|
||||
|
||||
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
|
||||
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
||||
width: pixelWidth,
|
||||
height: pixelHeight,
|
||||
positionX: pixelX,
|
||||
positionY: pixelY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageContext.pageNumber,
|
||||
type,
|
||||
positionX: fieldX,
|
||||
positionY: fieldY,
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
recipientId: editorFields.selectedRecipient.id,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove any pending fields or rectangle on the canvas.
|
||||
*/
|
||||
const removePendingField = () => {
|
||||
setPendingFieldCreation(null);
|
||||
|
||||
const pendingFieldCreation = pageLayer.current?.find('.pending-field-creation') || [];
|
||||
|
||||
for (const field of pendingFieldCreation) {
|
||||
field.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentEnvelopeItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
{selectedKonvaFieldGroups.length > 0 &&
|
||||
interactiveTransformer.current &&
|
||||
!isFieldChanging && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top:
|
||||
interactiveTransformer.current.y() +
|
||||
interactiveTransformer.current.getClientRect().height +
|
||||
5 +
|
||||
'px',
|
||||
left:
|
||||
interactiveTransformer.current.x() +
|
||||
interactiveTransformer.current.getClientRect().width / 2 +
|
||||
'px',
|
||||
transform: 'translateX(-50%)',
|
||||
gap: '8px',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 50,
|
||||
}}
|
||||
className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5"
|
||||
>
|
||||
<button
|
||||
title={t`Duplicate`}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={() => duplicatedSelectedFields()}
|
||||
onTouchEnd={() => duplicatedSelectedFields()}
|
||||
>
|
||||
<CopyPlusIcon className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={t`Duplicate on all pages`}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={() => duplicatedSelectedFieldsOnAllPages()}
|
||||
onTouchEnd={() => duplicatedSelectedFieldsOnAllPages()}
|
||||
>
|
||||
<SquareStackIcon className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={t`Remove`}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={() => deletedSelectedFields()}
|
||||
onTouchEnd={() => deletedSelectedFields()}
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Todo: Envelopes - This will not overflow the page when close to edges */}
|
||||
{pendingFieldCreation && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
|
||||
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 50,
|
||||
}}
|
||||
className="text-muted-foreground grid w-fit grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
|
||||
>
|
||||
{fieldButtonList.map((field) => (
|
||||
<button
|
||||
key={field.type}
|
||||
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
|
||||
className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
|
||||
>
|
||||
{t(field.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
Globe2Icon,
|
||||
LockIcon,
|
||||
RefreshCwIcon,
|
||||
SendIcon,
|
||||
SettingsIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
|
||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
|
||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
|
||||
export default function EnvelopeEditorHeader() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
// Todo: Envelopes this probably won't work with embed? Maybe hide the back items when no team?
|
||||
|
||||
const rootPath = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
return (
|
||||
<nav className="w-full border-b border-gray-200 bg-white px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/">
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<EnvelopeItemTitleInput
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
value={envelope.title}
|
||||
onChange={(title) => {
|
||||
updateEnvelope({
|
||||
title,
|
||||
});
|
||||
}}
|
||||
placeholder={t`Envelope Title`}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<>
|
||||
{envelope.templateType === 'PRIVATE' ? (
|
||||
<Badge variant="secondary">
|
||||
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
|
||||
<Trans>Private Template</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default">
|
||||
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Public Template</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{envelope.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="py-1"
|
||||
token={envelope.directLink.token}
|
||||
enabled={envelope.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT &&
|
||||
match(envelope.status)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<Badge variant="warning">
|
||||
<Trans>Draft</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => (
|
||||
<Badge variant="secondary">
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<Badge variant="default">
|
||||
<Trans>Completed</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(DocumentStatus.REJECTED, () => (
|
||||
<Badge variant="destructive">
|
||||
<Trans>Rejected</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
{autosaveError && (
|
||||
<>
|
||||
<Badge variant="destructive">
|
||||
<AlertTriangleIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Sync failed, changes not saved</Trans>
|
||||
</Badge>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
<Badge variant="destructive">
|
||||
<RefreshCwIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reload</Trans>
|
||||
</Badge>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<DocumentAttachmentsPopover envelopeId={envelope.id} />
|
||||
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isDocument && (
|
||||
<>
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={envelope}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<SendIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Send Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeRedistributeDialog
|
||||
envelope={envelope}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<SendIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Resend Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isTemplate && (
|
||||
<TemplateUseDialog
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
||||
recipients={envelope.recipients}
|
||||
documentRootPath={rootPath}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<Trans>Use Template</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,258 @@
|
||||
import { lazy, useEffect, useMemo } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type {
|
||||
TCheckboxFieldMeta,
|
||||
TDateFieldMeta,
|
||||
TDropdownFieldMeta,
|
||||
TEmailFieldMeta,
|
||||
TFieldMetaSchema,
|
||||
TInitialsFieldMeta,
|
||||
TNameFieldMeta,
|
||||
TNumberFieldMeta,
|
||||
TRadioFieldMeta,
|
||||
TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
||||
import { EditorFieldEmailForm } from '~/components/forms/editor/editor-field-email-form';
|
||||
import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-initials-form';
|
||||
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
|
||||
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
|
||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('./envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||
[FieldType.TEXT]: msg`Text Settings`,
|
||||
[FieldType.DATE]: msg`Date Settings`,
|
||||
[FieldType.EMAIL]: msg`Email Settings`,
|
||||
[FieldType.NAME]: msg`Name Settings`,
|
||||
[FieldType.INITIALS]: msg`Initials Settings`,
|
||||
[FieldType.NUMBER]: msg`Number Settings`,
|
||||
[FieldType.RADIO]: msg`Radio Settings`,
|
||||
[FieldType.CHECKBOX]: msg`Checkbox Settings`,
|
||||
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
||||
};
|
||||
|
||||
export const EnvelopeEditorPageFields = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
[editorFields.selectedField],
|
||||
);
|
||||
|
||||
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
|
||||
if (!selectedField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
|
||||
|
||||
// Todo: Envelopes - Clean up console logs.
|
||||
if (!isMetaSame) {
|
||||
console.log('TRIGGER UPDATE');
|
||||
editorFields.updateFieldByFormId(selectedField.formId, {
|
||||
fieldMeta,
|
||||
});
|
||||
} else {
|
||||
console.log('DATA IS SAME, NO UPDATE');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const firstSelectableRecipient = envelope.recipients.find(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER || recipient.role === RecipientRole.APPROVER,
|
||||
);
|
||||
|
||||
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex justify-center">
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
<p className="text-foreground mt-1 text-sm">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Please upload a document to continue</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section - Form Fields Panel */}
|
||||
{currentEnvelopeItem && (
|
||||
<div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
|
||||
{/* Recipient selector section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||
<Trans>Selected Recipient</Trans>
|
||||
</h3>
|
||||
|
||||
{envelope.recipients.length === 0 ? (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>You need at least one recipient to add fields</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<RecipientSelector
|
||||
selectedRecipient={editorFields.selectedRecipient}
|
||||
onSelectedRecipientChange={(recipient) =>
|
||||
editorFields.setSelectedRecipient(recipient.id)
|
||||
}
|
||||
recipients={envelope.recipients}
|
||||
className="w-full"
|
||||
align="end"
|
||||
/>
|
||||
)}
|
||||
|
||||
{editorFields.selectedRecipient &&
|
||||
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
||||
<Alert className="mt-4" variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
This recipient can no longer be modified as they have signed a field, or
|
||||
completed the document.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Add fields section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||
<Trans>Add Fields</Trans>
|
||||
</h3>
|
||||
|
||||
<EnvelopeEditorFieldDragDrop
|
||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
||||
{selectedField && selectedField.type !== FieldType.SIGNATURE && (
|
||||
<section>
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="[&_label]:text-foreground/70 px-4 [&_label]:text-xs">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
</h3>
|
||||
|
||||
{match(selectedField.type)
|
||||
.with(FieldType.CHECKBOX, () => (
|
||||
<EditorFieldCheckboxForm
|
||||
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<EditorFieldDateForm
|
||||
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.DROPDOWN, () => (
|
||||
<EditorFieldDropdownForm
|
||||
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EditorFieldEmailForm
|
||||
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
<EditorFieldInitialsForm
|
||||
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.NAME, () => (
|
||||
<EditorFieldNameForm
|
||||
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.NUMBER, () => (
|
||||
<EditorFieldNumberForm
|
||||
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.RADIO, () => (
|
||||
<EditorFieldRadioForm
|
||||
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.TEXT, () => (
|
||||
<EditorFieldTextForm
|
||||
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
|
||||
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import Konva from 'konva';
|
||||
import type { Layer } from 'konva/lib/Layer';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
import { usePageContext } from 'react-pdf';
|
||||
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
|
||||
export default function EnvelopeEditorPagePreviewRenderer() {
|
||||
const pageContext = usePageContext();
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { _className, page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Layer | null>(null);
|
||||
|
||||
const viewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
editorFields.localFields.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[editorFields.localFields, pageContext.pageNumber],
|
||||
);
|
||||
|
||||
// Custom renderer from Konva examples.
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport,
|
||||
};
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
createPageCanvas(container);
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, viewport],
|
||||
);
|
||||
|
||||
const renderFieldOnLayer = (field: TLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
renderField({
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.formId,
|
||||
...field,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
color: getRecipientColorKey(field.recipientId),
|
||||
editable: false,
|
||||
mode: 'export',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
stage.current?.add(pageLayer.current);
|
||||
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* Render fields when they are added or removed from the localFields.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!pageLayer.current || !stage.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If doesn't exist in localFields, destroy it since it's been deleted.
|
||||
pageLayer.current.find('Group').forEach((group) => {
|
||||
if (
|
||||
group.name() === 'field-group' &&
|
||||
!localPageFields.some((field) => field.formId === group.id())
|
||||
) {
|
||||
console.log('Field removed, removing from canvas');
|
||||
group.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// If it exists, rerender.
|
||||
localPageFields.forEach((field) => {
|
||||
console.log('Field created/updated, rendering on canvas');
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields]);
|
||||
|
||||
if (!currentEnvelopeItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
const EnvelopeEditorPagePreviewRenderer = lazy(
|
||||
async () => import('./envelope-editor-page-preview-renderer'),
|
||||
);
|
||||
|
||||
export const EnvelopeEditorPagePreview = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||
'recipient',
|
||||
);
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
useEffect(() => {
|
||||
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex flex-col items-center justify-center">
|
||||
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
<p className="text-foreground mt-1 text-sm">
|
||||
<Trans>No documents found</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Please upload a document to continue</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section - Form Fields Panel */}
|
||||
{currentEnvelopeItem && false && (
|
||||
<div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
|
||||
{/* Add fields section. */}
|
||||
<section className="px-4">
|
||||
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||
<Trans>Preivew Mode</Trans>
|
||||
</h3> */}
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* <Alert variant="neutral">
|
||||
<RadioGroup
|
||||
className="gap-y-1"
|
||||
value={selectedPreviewMode}
|
||||
onValueChange={(value) => setSelectedPreviewMode(value as 'recipient' | 'signed')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<RadioGroupItem
|
||||
id="document-signed-preview"
|
||||
className="pointer-events-none h-3 w-3"
|
||||
value="signed"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="document-signed-preview"
|
||||
className="text-foreground ml-1.5 text-xs font-normal"
|
||||
>
|
||||
<Trans>Document Signed Preview</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<RadioGroupItem
|
||||
id="recipient-preview"
|
||||
className="pointer-events-none h-3 w-3"
|
||||
value="recipient"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="recipient-preview"
|
||||
className="text-foreground ml-1.5 text-xs font-normal"
|
||||
>
|
||||
<Trans>Recipient Preview</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</Alert>
|
||||
|
||||
<div>Preview what a recipient will see</div>
|
||||
|
||||
<div>Preview the signed document</div> */}
|
||||
</section>
|
||||
|
||||
{false && (
|
||||
<AnimateGenericFadeInOut key={selectedPreviewMode}>
|
||||
{selectedPreviewMode === 'recipient' && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Recipient selector section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||
<Trans>Selected Recipient</Trans>
|
||||
</h3>
|
||||
|
||||
<RecipientSelector
|
||||
selectedRecipient={editorFields.selectedRecipient}
|
||||
onSelectedRecipientChange={(recipient) =>
|
||||
editorFields.setSelectedRecipient(recipient.id)
|
||||
}
|
||||
recipients={envelope.recipients}
|
||||
className="w-full"
|
||||
align="end"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,348 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import {
|
||||
useCurrentEnvelopeEditor,
|
||||
useDebounceFunction,
|
||||
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@documenso/ui/primitives/card';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
|
||||
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
|
||||
type LocalFile = {
|
||||
id: string;
|
||||
title: string;
|
||||
envelopeItemId: string | null;
|
||||
isUploading: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorPageUpload = () => {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useLingui();
|
||||
|
||||
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
||||
envelope.envelopeItems
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
envelopeItemId: item.id,
|
||||
isUploading: false,
|
||||
isError: false,
|
||||
})),
|
||||
);
|
||||
|
||||
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
|
||||
trpc.envelope.item.createMany.useMutation({
|
||||
onSuccess: (data) => {
|
||||
const createdEnvelopes = data.createdEnvelopeItems.filter(
|
||||
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
|
||||
);
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: [...envelope.envelopeItems, ...createdEnvelopes],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((originalItem) => {
|
||||
const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
|
||||
|
||||
if (updatedItem) {
|
||||
return {
|
||||
...originalItem,
|
||||
...updatedItem,
|
||||
};
|
||||
}
|
||||
|
||||
return originalItem;
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const canItemsBeModified = useMemo(
|
||||
() => canEnvelopeItemsBeModified(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
);
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({
|
||||
id: nanoid(),
|
||||
envelopeItemId: null,
|
||||
title: file.name,
|
||||
file,
|
||||
isUploading: true,
|
||||
isError: false,
|
||||
}));
|
||||
|
||||
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
||||
|
||||
const result = await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
try {
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
// Mark as uploaded (remove from uploading state)
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
};
|
||||
} catch (_error) {
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((uploadingFile) =>
|
||||
uploadingFile.id === newUploadingFiles[index].id
|
||||
? { ...uploadingFile, isError: true, isUploading: false }
|
||||
: uploadingFile,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const envelopeItemsToCreate = result.filter(
|
||||
(item): item is { title: string; documentDataId: string } => item !== undefined,
|
||||
);
|
||||
|
||||
const { createdEnvelopeItems } = await createEnvelopeItems({
|
||||
envelopeId: envelope.id,
|
||||
items: envelopeItemsToCreate,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
// Set error state on files in batch upload.
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((uploadingFile) =>
|
||||
uploadingFile.id === newUploadingFiles.find((file) => file.id === uploadingFile.id)?.id
|
||||
? { ...uploadingFile, isError: true, isUploading: false }
|
||||
: uploadingFile,
|
||||
),
|
||||
);
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
setLocalFiles((prev) => {
|
||||
const filteredFiles = prev.filter(
|
||||
(uploadingFile) =>
|
||||
uploadingFile.id !== newUploadingFiles.find((file) => file.id === uploadingFile.id)?.id,
|
||||
);
|
||||
|
||||
return filteredFiles.concat(
|
||||
createdEnvelopeItems.map((item) => ({
|
||||
id: item.id,
|
||||
envelopeItemId: item.id,
|
||||
title: item.title,
|
||||
isUploading: false,
|
||||
isError: false,
|
||||
})),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide the envelope item from the list on deletion.
|
||||
*/
|
||||
const onFileDelete = (envelopeItemId: string) => {
|
||||
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle drag end for reordering files.
|
||||
*/
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.from(localFiles);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
setLocalFiles(items);
|
||||
debouncedUpdateEnvelopeItems(items);
|
||||
};
|
||||
|
||||
// Todo: Envelopes - Sync into envelopes data
|
||||
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
|
||||
void updateEnvelopeItems({
|
||||
envelopeId: envelope.id,
|
||||
data: files
|
||||
.filter((item) => item.envelopeItemId)
|
||||
.map((item, index) => ({
|
||||
envelopeItemId: item.envelopeItemId || '',
|
||||
order: index + 1,
|
||||
title: item.title,
|
||||
})),
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
const onEnvelopeItemTitleChange = (envelopeItemId: string, title: string) => {
|
||||
const newLocalFilesValue = localFiles.map((uploadingFile) =>
|
||||
uploadingFile.envelopeItemId === envelopeItemId ? { ...uploadingFile, title } : uploadingFile,
|
||||
);
|
||||
|
||||
setLocalFiles(newLocalFilesValue);
|
||||
debouncedUpdateEnvelopeItems(newLocalFilesValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CardDescription>Add and configure multiple documents</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<DocumentDropzone
|
||||
onDrop={onFileDrop}
|
||||
allowMultiple
|
||||
className="pb-4 pt-6"
|
||||
disabled={!canItemsBeModified}
|
||||
disabledMessage={msg`Cannot upload items after the document has been sent`}
|
||||
disabledHeading={msg`Upload disabled`}
|
||||
/>
|
||||
|
||||
{/* Uploaded Files List */}
|
||||
<div className="mt-4">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="files">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
|
||||
{localFiles.map((localFile, index) => (
|
||||
<Draggable
|
||||
key={localFile.id}
|
||||
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
|
||||
draggableId={localFile.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={provided.draggableProps.style}
|
||||
className={`flex items-center justify-between rounded-lg bg-gray-50 p-3 transition-shadow ${
|
||||
snapshot.isDragging ? 'shadow-md' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{localFile.envelopeItemId !== null ? (
|
||||
<EnvelopeItemTitleInput
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
value={localFile.title}
|
||||
placeholder={t`Document Title`}
|
||||
onChange={(title) => {
|
||||
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium">{localFile.title}</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{localFile.isUploading ? (
|
||||
<Trans>Uploading</Trans>
|
||||
) : localFile.isError ? (
|
||||
<Trans>Something went wrong while uploading this file</Trans>
|
||||
) : // <div className="text-xs text-gray-500">2.4 MB • 3 pages</div>
|
||||
null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{localFile.isUploading && (
|
||||
<div className="flex h-6 w-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localFile.isError && (
|
||||
<div className="flex h-6 w-10 items-center justify-center">
|
||||
<FileWarningIcon className="text-destructive h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!localFile.isUploading && localFile.envelopeItemId && (
|
||||
<EnvelopeItemDeleteDialog
|
||||
canItemBeDeleted={canItemsBeModified}
|
||||
envelopeId={envelope.id}
|
||||
envelopeItemId={localFile.envelopeItemId}
|
||||
envelopeItemTitle={localFile.title}
|
||||
onDelete={onFileDelete}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recipients Section */}
|
||||
<EnvelopeEditorRecipientForm />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button asChild>
|
||||
<Link to={`/t/${team.url}/documents/${envelope.id}/edit?step=addFields`}>
|
||||
<Trans>Add Fields</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,941 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
type DropResult,
|
||||
Droppable,
|
||||
type SensorAPI,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
|
||||
import {
|
||||
RecipientAutoCompleteInput,
|
||||
type RecipientAutoCompleteOption,
|
||||
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@documenso/ui/primitives/card';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZEnvelopeRecipientsForm = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
// Todo: Envelopes - These aren't synced to the server
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
|
||||
|
||||
export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced } = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { user } = useSession();
|
||||
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
const { recipients, fields } = envelope;
|
||||
|
||||
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
|
||||
{
|
||||
query: debouncedRecipientSearchQuery,
|
||||
},
|
||||
{
|
||||
enabled: debouncedRecipientSearchQuery.length > 1,
|
||||
},
|
||||
);
|
||||
|
||||
const recipientSuggestions = recipientSuggestionsData?.results || [];
|
||||
|
||||
const defaultRecipients = [
|
||||
{
|
||||
formId: initialId,
|
||||
name: '',
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
actionAuth: [],
|
||||
},
|
||||
];
|
||||
|
||||
const form = useForm<TEnvelopeRecipientsForm>({
|
||||
resolver: zodResolver(ZEnvelopeRecipientsForm),
|
||||
mode: 'onChange', // Used for autosave purposes, maybe can try onBlur instead?
|
||||
defaultValues: {
|
||||
signers:
|
||||
recipients.length > 0
|
||||
? sortBy(
|
||||
recipients.map((recipient, index) => ({
|
||||
id: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder ?? index + 1,
|
||||
actionAuth:
|
||||
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
})),
|
||||
[prop('signingOrder'), 'asc'],
|
||||
[prop('id'), 'asc'],
|
||||
)
|
||||
: defaultRecipients,
|
||||
signingOrder: envelope.documentMeta.signingOrder,
|
||||
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner,
|
||||
},
|
||||
});
|
||||
|
||||
// Always show advanced settings if any recipient has auth options.
|
||||
const alwaysShowAdvancedSettings = useMemo(() => {
|
||||
const recipientHasAuthOptions = recipients.find((recipient) => {
|
||||
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||
|
||||
return (
|
||||
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const formHasActionAuth = form
|
||||
.getValues('signers')
|
||||
.find((signer) => signer.actionAuth.length > 0);
|
||||
|
||||
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
|
||||
}, [recipients, form]);
|
||||
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
||||
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
||||
|
||||
const {
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
control,
|
||||
watch,
|
||||
} = form;
|
||||
|
||||
const formValues = useWatch({
|
||||
control,
|
||||
});
|
||||
|
||||
const watchedSigners = watch('signers');
|
||||
const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
|
||||
|
||||
const hasAssistantRole = useMemo(() => {
|
||||
return watchedSigners.some((signer) => signer.role === RecipientRole.ASSISTANT);
|
||||
}, [watchedSigners]);
|
||||
|
||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||
return signers
|
||||
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
|
||||
.map((signer, index) => ({ ...signer, signingOrder: index + 1 }));
|
||||
};
|
||||
|
||||
const {
|
||||
append: appendSigner,
|
||||
fields: signers,
|
||||
remove: removeSigner,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: 'signers',
|
||||
keyName: 'nativeId',
|
||||
});
|
||||
|
||||
const emptySigners = useCallback(
|
||||
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
||||
[form],
|
||||
);
|
||||
|
||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||
const isUserAlreadyARecipient = watchedSigners.some(
|
||||
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
||||
);
|
||||
|
||||
const hasDocumentBeenSent = recipients.some(
|
||||
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
||||
);
|
||||
|
||||
const canRecipientBeModified = (recipientId?: number) => {
|
||||
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (recipientId === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const recipient = recipients.find((recipient) => recipient.id === recipientId);
|
||||
|
||||
if (!recipient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return utilCanRecipientBeModified(recipient, fields);
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
name: '',
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveSigner = (index: number) => {
|
||||
const signer = signers[index];
|
||||
|
||||
if (!canRecipientBeModified(signer.id)) {
|
||||
toast({
|
||||
title: t`Cannot remove signer`,
|
||||
description: t`This signer has already signed the document.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
|
||||
if (formStateIndex !== -1) {
|
||||
removeSigner(formStateIndex);
|
||||
|
||||
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
|
||||
|
||||
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSelfSigner = () => {
|
||||
if (emptySignerIndex !== -1) {
|
||||
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
form.setFocus(`signers.${emptySignerIndex}.email`);
|
||||
} else {
|
||||
appendSigner(
|
||||
{
|
||||
formId: nanoid(12),
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: [],
|
||||
signingOrder:
|
||||
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
},
|
||||
{
|
||||
shouldFocus: true,
|
||||
},
|
||||
);
|
||||
|
||||
void form.trigger('signers');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecipientAutoCompleteSelect = (
|
||||
index: number,
|
||||
suggestion: RecipientAutoCompleteOption,
|
||||
) => {
|
||||
setValue(`signers.${index}.email`, suggestion.email);
|
||||
setValue(`signers.${index}.name`, suggestion.name || '');
|
||||
};
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
async (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(watchedSigners);
|
||||
const [reorderedSigner] = items.splice(result.source.index, 1);
|
||||
|
||||
// Find next valid position
|
||||
let insertIndex = result.destination.index;
|
||||
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) {
|
||||
insertIndex++;
|
||||
}
|
||||
|
||||
items.splice(insertIndex, 0, reorderedSigner);
|
||||
|
||||
const updatedSigners = items.map((signer, index) => ({
|
||||
...signer,
|
||||
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : index + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||
toast({
|
||||
title: t`Warning: Assistant as last signer`,
|
||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
});
|
||||
}
|
||||
|
||||
await form.trigger('signers');
|
||||
},
|
||||
[form, canRecipientBeModified, watchedSigners, toast],
|
||||
);
|
||||
|
||||
const handleRoleChange = useCallback(
|
||||
(index: number, role: RecipientRole) => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signingOrder = form.getValues('signingOrder');
|
||||
|
||||
// Handle parallel to sequential conversion for assistants
|
||||
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
toast({
|
||||
title: t`Signing order is enabled.`,
|
||||
description: t`You cannot add assistants when signing order is disabled.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSigners = currentSigners.map((signer, idx) => ({
|
||||
...signer,
|
||||
role: idx === index ? role : signer.role,
|
||||
signingOrder: !canRecipientBeModified(signer.id) ? signer.signingOrder : idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||
toast({
|
||||
title: t`Warning: Assistant as last signer`,
|
||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[form, toast, canRecipientBeModified],
|
||||
);
|
||||
|
||||
const handleSigningOrderChange = useCallback(
|
||||
(index: number, newOrderString: string) => {
|
||||
const trimmedOrderString = newOrderString.trim();
|
||||
if (!trimmedOrderString) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrder = Number(trimmedOrderString);
|
||||
if (!Number.isInteger(newOrder) || newOrder < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signer = currentSigners[index];
|
||||
|
||||
// Remove signer from current position and insert at new position
|
||||
const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||
remainingSigners.splice(newPosition, 0, signer);
|
||||
|
||||
const updatedSigners = remainingSigners.map((s, idx) => ({
|
||||
...s,
|
||||
signingOrder: !canRecipientBeModified(s.id) ? s.signingOrder : idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||
toast({
|
||||
title: t`Warning: Assistant as last signer`,
|
||||
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[form, canRecipientBeModified, toast],
|
||||
);
|
||||
|
||||
const handleSigningOrderDisable = useCallback(() => {
|
||||
setShowSigningOrderConfirmation(false);
|
||||
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue('allowDictateNextSigner', false, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}, [form]);
|
||||
|
||||
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
console.log('validatedFormValues', validatedFormValues);
|
||||
|
||||
setRecipientsDebounced(validatedFormValues.data.signers);
|
||||
|
||||
// Todo: Envelopes - Need to save the other data as well
|
||||
// setEnvelope
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
return (
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
<div>
|
||||
<CardTitle>Recipients</CardTitle>
|
||||
<CardDescription className="mt-1.5">Add recipients to your document</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-row items-center"
|
||||
size="sm"
|
||||
disabled={isSubmitting || isUserAlreadyARecipient}
|
||||
onClick={() => onAddSelfSigner()}
|
||||
>
|
||||
<Trans>Add Myself</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||
onClick={() => onAddSigner()}
|
||||
>
|
||||
<PlusIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||
<Trans>Add Signer</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||
<Form {...form}>
|
||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-gray-50/80 p-4">
|
||||
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="showAdvancedRecipientSettings"
|
||||
className="h-5 w-5"
|
||||
checked={showAdvancedSettings}
|
||||
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
htmlFor="showAdvancedRecipientSettings"
|
||||
>
|
||||
<Trans>Show advanced settings</Trans>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signingOrder"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
{...field}
|
||||
id="signingOrder"
|
||||
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!checked && hasAssistantRole) {
|
||||
setShowSigningOrderConfirmation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(
|
||||
checked
|
||||
? DocumentSigningOrder.SEQUENTIAL
|
||||
: DocumentSigningOrder.PARALLEL,
|
||||
);
|
||||
|
||||
// If sequential signing is turned off, disable dictate next signer
|
||||
if (!checked) {
|
||||
form.setValue('allowDictateNextSigner', false, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
<FormLabel
|
||||
htmlFor="signingOrder"
|
||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Trans>Enable signing order</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 p-4">
|
||||
<p>
|
||||
<Trans>Add 2 or more signers to enable signing order.</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isSigningOrderSequential && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowDictateNextSigner"
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
{...field}
|
||||
id="allowDictateNextSigner"
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
}}
|
||||
disabled={
|
||||
isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
<FormLabel
|
||||
htmlFor="allowDictateNextSigner"
|
||||
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Trans>Allow signers to dictate next signer</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
When enabled, signers can choose who should sign next in the
|
||||
sequence instead of following the predefined order.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragDropContext
|
||||
onDragEnd={onDragEnd}
|
||||
sensors={[
|
||||
(api: SensorAPI) => {
|
||||
$sensorApi.current = api;
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Droppable droppableId="signers">
|
||||
{(provided) => (
|
||||
<div
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
className="flex w-full flex-col gap-y-2"
|
||||
>
|
||||
{signers.map((signer, index) => (
|
||||
<Draggable
|
||||
key={`${signer.id}-${signer.signingOrder}`}
|
||||
draggableId={signer['nativeId']}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
!isSigningOrderSequential ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
!signer.signingOrder
|
||||
}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn('py-1', {
|
||||
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||
snapshot.isDragging,
|
||||
})}
|
||||
>
|
||||
<motion.fieldset
|
||||
data-native-id={signer.id}
|
||||
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
|
||||
className={cn('grid grid-cols-10 items-end gap-2 pb-2', {
|
||||
'border-b pt-2': showAdvancedSettings,
|
||||
'grid-cols-12 pr-3': isSigningOrderSequential,
|
||||
})}
|
||||
>
|
||||
{isSigningOrderSequential && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.signingOrder`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn(
|
||||
'col-span-1 mt-auto flex items-center gap-x-1 space-y-0',
|
||||
{
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.signingOrder,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
max={signers.length}
|
||||
data-testid="signing-order-input"
|
||||
className={cn(
|
||||
'w-full text-center',
|
||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||
)}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
handleSigningOrderChange(index, e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
handleSigningOrderChange(index, e.target.value);
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('relative', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.email,
|
||||
'col-span-4': !showAdvancedSettings,
|
||||
'col-span-5': showAdvancedSettings,
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel required>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<RecipientAutoCompleteInput
|
||||
type="email"
|
||||
placeholder={t`Email`}
|
||||
value={field.value}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
data-testid="signer-email-input"
|
||||
maxLength={254}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn({
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.name,
|
||||
'col-span-4': !showAdvancedSettings,
|
||||
'col-span-5': showAdvancedSettings,
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<RecipientAutoCompleteInput
|
||||
type="text"
|
||||
placeholder={t`Name`}
|
||||
{...field}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
maxLength={255}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{showAdvancedSettings &&
|
||||
organisation.organisationClaim.flags.cfr21 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.actionAuth`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('col-span-8', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.actionAuth,
|
||||
'col-span-10': isSigningOrderSequential,
|
||||
})}
|
||||
>
|
||||
<FormControl>
|
||||
<RecipientActionAuthSelect
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="col-span-2 flex gap-x-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('mt-auto', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.role,
|
||||
})}
|
||||
>
|
||||
<FormControl>
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
isAssistantEnabled={isSigningOrderSequential}
|
||||
onValueChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole);
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
'mb-6': form.formState.errors.signers?.[index],
|
||||
},
|
||||
)}
|
||||
data-testid="remove-signer-button"
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
signers.length === 1
|
||||
}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.fieldset>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
|
||||
<FormErrorMessage
|
||||
className="mt-2"
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
error={'signers__root' in errors && errors['signers__root']}
|
||||
/>
|
||||
</Form>
|
||||
</AnimateGenericFadeInOut>
|
||||
|
||||
<SigningOrderConfirmation
|
||||
open={showSigningOrderConfirmation}
|
||||
onOpenChange={setShowSigningOrderConfirmation}
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,832 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentVisibility,
|
||||
EnvelopeType,
|
||||
SendStatus,
|
||||
} from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import {
|
||||
DOCUMENT_DISTRIBUTION_METHODS,
|
||||
DOCUMENT_SIGNATURE_TYPES,
|
||||
} from '@documenso/lib/constants/document';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
ZDocumentAccessAuthTypesSchema,
|
||||
ZDocumentActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import {
|
||||
type TDocumentMetaDateFormat,
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
|
||||
import {
|
||||
DocumentSignatureType,
|
||||
canAccessTeamDocument,
|
||||
extractTeamSignatureSettings,
|
||||
} from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||
import {
|
||||
DocumentGlobalAuthAccessSelect,
|
||||
DocumentGlobalAuthAccessTooltip,
|
||||
} from '@documenso/ui/components/document/document-global-auth-access-select';
|
||||
import {
|
||||
DocumentGlobalAuthActionSelect,
|
||||
DocumentGlobalAuthActionTooltip,
|
||||
} from '@documenso/ui/components/document/document-global-auth-action-select';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import {
|
||||
DocumentVisibilitySelect,
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export const ZAddSettingsFormSchema = z.object({
|
||||
externalId: z.string().optional(),
|
||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||
globalAccessAuth: z
|
||||
.array(z.union([ZDocumentAccessAuthTypesSchema, z.literal('-1')]))
|
||||
.transform((val) => (val.length === 1 && val[0] === '-1' ? [] : val))
|
||||
.optional()
|
||||
.default([]),
|
||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
|
||||
meta: z.object({
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
timezone: ZDocumentMetaTimezoneSchema.default(DEFAULT_DOCUMENT_TIME_ZONE),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.default(DEFAULT_DOCUMENT_DATE_FORMAT),
|
||||
distributionMethod: z
|
||||
.nativeEnum(DocumentDistributionMethod)
|
||||
.optional()
|
||||
.default(DocumentDistributionMethod.EMAIL),
|
||||
redirectUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
|
||||
message:
|
||||
'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
|
||||
}),
|
||||
language: z
|
||||
.union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)])
|
||||
.optional()
|
||||
.default('en'),
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().email().optional(),
|
||||
),
|
||||
emailSettings: ZDocumentEmailSettingsSchema,
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'email' | 'security';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'general',
|
||||
title: msg`General`,
|
||||
icon: SettingsIcon,
|
||||
description: msg`Configure document settings and options before sending.`,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: msg`Email`,
|
||||
icon: MailIcon,
|
||||
description: msg`Configure email settings for the document`,
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
title: msg`Security`,
|
||||
icon: ShieldIcon,
|
||||
description: msg`Configure security settings for the document`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TAddSettingsFormSchema = z.infer<typeof ZAddSettingsFormSchema>;
|
||||
|
||||
type EnvelopeEditorSettingsDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopeEditorSettingsDialog = ({
|
||||
trigger,
|
||||
...props
|
||||
}: EnvelopeEditorSettingsDialogProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { envelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<EnvelopeEditorSettingsTabType>('general');
|
||||
|
||||
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
});
|
||||
|
||||
const form = useForm<TAddSettingsFormSchema>({
|
||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||
defaultValues: {
|
||||
externalId: envelope.externalId || '', // Todo: String or undefined?
|
||||
visibility: envelope.visibility || '',
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
||||
globalActionAuth: documentAuthOption?.globalActionAuth || [],
|
||||
|
||||
meta: {
|
||||
subject: envelope.documentMeta.subject ?? '',
|
||||
message: envelope.documentMeta.message ?? '',
|
||||
timezone: envelope.documentMeta.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
dateFormat: (envelope.documentMeta.dateFormat ??
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT) as TDocumentMetaDateFormat,
|
||||
distributionMethod:
|
||||
envelope.documentMeta.distributionMethod || DocumentDistributionMethod.EMAIL,
|
||||
redirectUrl: envelope.documentMeta.redirectUrl ?? '',
|
||||
language: envelope.documentMeta.language ?? 'en',
|
||||
emailId: envelope.documentMeta.emailId ?? null,
|
||||
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
|
||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
|
||||
|
||||
// Todo: Envelopes - Extract into provider.
|
||||
const envelopeHasBeenSent =
|
||||
envelope.type === EnvelopeType.DOCUMENT &&
|
||||
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
|
||||
|
||||
const emailSettings = form.watch('meta.emailSettings');
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
// Todo: Envelopes this doesn't make sense (look at previous)
|
||||
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
|
||||
|
||||
const onFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
try {
|
||||
await updateEnvelope({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
data: {
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
|
||||
globalActionAuth: data.globalActionAuth ?? [],
|
||||
},
|
||||
meta: {
|
||||
timezone,
|
||||
dateFormat,
|
||||
redirectUrl,
|
||||
language: isValidLanguageCode(language) ? language : undefined,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
},
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Envelope updated`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to update the envelope. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!form.formState.touchedFields.meta?.timezone &&
|
||||
!envelopeHasBeenSent &&
|
||||
!envelope.documentMeta.timezone
|
||||
) {
|
||||
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
}
|
||||
}, [
|
||||
envelopeHasBeenSent,
|
||||
form,
|
||||
form.setValue,
|
||||
form.formState.touchedFields.meta?.timezone,
|
||||
envelope.documentMeta.timezone,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
setActiveTab('general');
|
||||
}, [open, form]);
|
||||
|
||||
// Todo: Envelopes - Show error indicator if error is in different tab.
|
||||
|
||||
const selectedTab = tabs.find((tab) => tab.id === activeTab);
|
||||
|
||||
if (!selectedTab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
<Trans>Settings</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
|
||||
{/* Sidebar. */}
|
||||
<div className="flex w-80 flex-col border-r bg-gray-50">
|
||||
<DialogHeader className="p-6 pb-4">
|
||||
<DialogTitle>Document Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', {
|
||||
'bg-secondary': activeTab === tab.id,
|
||||
})}
|
||||
>
|
||||
<tab.icon className="mr-2 h-5 w-5" />
|
||||
{t(tab.title)}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content. */}
|
||||
<div className="flex w-full flex-col">
|
||||
<CardHeader className="border-b pb-4">
|
||||
<CardTitle>{t(selectedTab?.title ?? '')}</CardTitle>
|
||||
<CardDescription>{t(selectedTab?.description ?? '')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex min-h-[45rem] w-full flex-col space-y-6 px-6 pt-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
key={activeTab}
|
||||
>
|
||||
{match(activeTab)
|
||||
.with('general', () => (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center">
|
||||
<Trans>Language</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<Trans>
|
||||
Controls the language for the document, including the language
|
||||
to be used for email notifications, and the final certificate
|
||||
that is generated and attached to the document.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{language.full}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.signatureTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Allowed Signature Types</Trans>
|
||||
<DocumentSignatureSettingsTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||
label: t(option.label),
|
||||
value: option.value,
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
emptySelectionPlaceholder="Select signature types"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.dateFormat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Date Format</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={envelopeHasBeenSent}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{DATE_FORMATS.map((format) => (
|
||||
<SelectItem key={format.key} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.timezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Time Zone</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Combobox
|
||||
className="bg-background"
|
||||
options={TIME_ZONES}
|
||||
value={field.value}
|
||||
onChange={(value) => value && field.onChange(value)}
|
||||
disabled={envelopeHasBeenSent}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>External ID</Trans>{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<Trans>
|
||||
Add an external ID to the document. This can be used to identify
|
||||
the document in external systems.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.redirectUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Redirect URL</Trans>{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<Trans>
|
||||
Add a URL to redirect the user to once the document is signed
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.distributionMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Document Distribution Method</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Document Distribution Method</Trans>
|
||||
</strong>
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
This is how the document will reach the recipients once the
|
||||
document is ready for signing.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||
<li>
|
||||
<Trans>
|
||||
<strong>Email</strong> - The recipient will be emailed the
|
||||
document to sign, approve, etc.
|
||||
</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>
|
||||
<strong>None</strong> - We will generate links which you can
|
||||
send to the recipients manually.
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Trans>
|
||||
<strong>Note</strong> - If you use Links in combination with
|
||||
direct templates, you will need to manually send the links to
|
||||
the remaining recipients.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue data-testid="documentDistributionMethodSelectValue" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
|
||||
({ value, description }) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{i18n._(description)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
.with('email', () => (
|
||||
<>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Sender</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === '-1' ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
loading={isLoadingEmails}
|
||||
className="bg-background"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{emails.map((email) => (
|
||||
<SelectItem key={email.id} value={email.id}>
|
||||
{email.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>Documenso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailReplyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply To Email</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Message</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground p-4">
|
||||
<DocumentSendEmailMessageHelper />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="bg-background h-16 resize-none" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DocumentEmailCheckboxes
|
||||
value={emailSettings}
|
||||
onChange={(value) => form.setValue('meta.emailSettings', value)}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
.with('security', () => (
|
||||
<>
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalAccessAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Document access</Trans>
|
||||
<DocumentGlobalAuthAccessTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthAccessSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Document visibility</Trans>
|
||||
<DocumentVisibilityTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<DocumentVisibilitySelect
|
||||
canUpdateVisibility={canUpdateVisibility}
|
||||
currentTeamMemberRole={team.currentTeamRole}
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-row justify-end gap-4 p-6">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type EnvelopeItemTitleInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const EnvelopeItemTitleInput = ({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: EnvelopeItemTitleInputProps) => {
|
||||
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const [inputWidth, setInputWidth] = useState(200);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Update input width based on content
|
||||
useEffect(() => {
|
||||
if (measureRef.current) {
|
||||
const width = measureRef.current.offsetWidth;
|
||||
setInputWidth(Math.max(width + 16, 100)); // Add padding and minimum width
|
||||
}
|
||||
}, [envelopeItemTitle]);
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
if (title === '') {
|
||||
setIsError(true);
|
||||
}
|
||||
|
||||
setEnvelopeItemTitle(title);
|
||||
|
||||
const parsedTitle = ZDocumentTitleSchema.safeParse(title);
|
||||
|
||||
if (!parsedTitle.success) {
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsError(false);
|
||||
|
||||
onChange(parsedTitle.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Hidden span to measure text width */}
|
||||
<span
|
||||
ref={measureRef}
|
||||
className="pointer-events-none absolute left-0 top-0 whitespace-nowrap text-sm font-medium text-gray-600 opacity-0"
|
||||
style={{ font: 'inherit' }}
|
||||
>
|
||||
{envelopeItemTitle || placeholder}
|
||||
</span>
|
||||
<input
|
||||
data-1p-ignore
|
||||
autoComplete="off"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={envelopeItemTitle}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ width: `${inputWidth}px` }}
|
||||
className={cn(
|
||||
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1',
|
||||
className,
|
||||
{
|
||||
'outline-red-500': isError,
|
||||
},
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,356 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CopyPlusIcon,
|
||||
DownloadCloudIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
MousePointer,
|
||||
SendIcon,
|
||||
SettingsIcon,
|
||||
Trash2Icon,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import {
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
|
||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import EnvelopeEditorHeader from './envelope-editor-header';
|
||||
import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
|
||||
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
|
||||
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
|
||||
|
||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
|
||||
const envelopeEditorSteps = [
|
||||
{
|
||||
id: 'upload',
|
||||
order: 1,
|
||||
title: msg`Document & Recipients`,
|
||||
icon: Upload,
|
||||
description: msg`Upload documents and add recipients`,
|
||||
},
|
||||
{
|
||||
id: 'addFields',
|
||||
order: 2,
|
||||
title: msg`Add Fields`,
|
||||
icon: MousePointer,
|
||||
description: msg`Place and configure form fields in the document`,
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
order: 3,
|
||||
title: msg`Preview`,
|
||||
icon: EyeIcon,
|
||||
description: msg`Preview the document before sending`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function EnvelopeEditor() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, isDocument, isTemplate, isAutosaving, flushAutosave } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isStepLoading, setIsStepLoading] = useState(false);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
|
||||
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
|
||||
|
||||
// Empty URL param equals upload, otherwise use the step URL param
|
||||
if (!searchParamStep) {
|
||||
return 'upload';
|
||||
}
|
||||
|
||||
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview'];
|
||||
|
||||
if (validSteps.includes(searchParamStep)) {
|
||||
return searchParamStep;
|
||||
}
|
||||
|
||||
return 'upload';
|
||||
});
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
const navigateToStep = (step: EnvelopeEditorStep) => {
|
||||
setCurrentStep(step);
|
||||
|
||||
flushAutosave();
|
||||
|
||||
if (!isStepLoading && isAutosaving) {
|
||||
setIsStepLoading(true);
|
||||
}
|
||||
|
||||
// Update URL params: empty for upload, otherwise set the step
|
||||
if (step === 'upload') {
|
||||
setSearchParams((prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.delete('step');
|
||||
return newParams;
|
||||
});
|
||||
} else {
|
||||
setSearchParams((prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('step', step);
|
||||
return newParams;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAutosaving) {
|
||||
setIsStepLoading(false);
|
||||
}
|
||||
}, [isAutosaving]);
|
||||
|
||||
const currentStepData =
|
||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-gray-50">
|
||||
<EnvelopeEditorHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-73px)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-gray-200 bg-white py-4">
|
||||
{/* Left section step selector. */}
|
||||
<div className="px-4">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-gray-900">
|
||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||
|
||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="bg-muted relative my-4 h-[4px] rounded-md">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
style={{
|
||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{envelopeEditorSteps.map((step) => {
|
||||
const Icon = step.icon;
|
||||
const isActive = currentStep === step.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`cursor-pointer rounded-lg p-3 transition-colors ${
|
||||
isActive
|
||||
? 'border border-green-200 bg-green-50'
|
||||
: 'border border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className={`rounded border p-2 ${
|
||||
isActive ? 'border-green-200 bg-green-50' : 'border-gray-100 bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
isActive ? 'text-green-900' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t(step.title)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{t(step.description)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<div className="space-y-3 px-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
<Trans>Quick Actions</Trans>
|
||||
</h4>
|
||||
{isDocument && (
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={envelope}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<SendIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Send Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDocument && (
|
||||
<EnvelopeRedistributeDialog
|
||||
envelope={envelope}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<SendIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Resend Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Todo: Envelopes */}
|
||||
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Save as Template
|
||||
</Button> */}
|
||||
|
||||
{isTemplate && (
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
directLink={envelope.directLink}
|
||||
recipients={envelope.recipients}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Direct Link</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EnvelopeDuplicateDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeType={envelope.type}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<CopyPlusIcon className="mr-2 h-4 w-4" />
|
||||
{isDocument ? (
|
||||
<Trans>Duplicate Document</Trans>
|
||||
) : (
|
||||
<Trans>Duplicate Template</Trans>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isDocument ? (
|
||||
<DocumentDeleteDialog
|
||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
status={envelope.status}
|
||||
documentTitle={envelope.title}
|
||||
canManageDocument={true}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={async () => {
|
||||
await navigate(documentsPath);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TemplateDeleteDialog
|
||||
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={async () => {
|
||||
await navigate(templatesPath);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer of left sidebar. */}
|
||||
<div className="mt-auto px-4">
|
||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||
<Link to={isDocument ? documentsPath : templatesPath}>
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
{isDocument ? (
|
||||
<Trans>Return to documents</Trans>
|
||||
) : (
|
||||
<Trans>Return to templates</Trans>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Changes based on current step */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
|
||||
<AnimateGenericFadeInOut key={currentStep}>
|
||||
{match({ currentStep, isStepLoading })
|
||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
|
||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
|
||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
|
||||
.exhaustive()}
|
||||
</AnimateGenericFadeInOut>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { Plural } from '@lingui/react/macro';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
type EnvelopeItemSelectorProps = {
|
||||
number: number;
|
||||
primaryText: React.ReactNode;
|
||||
secondaryText: React.ReactNode;
|
||||
isSelected: boolean;
|
||||
buttonProps: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
export const EnvelopeItemSelector = ({
|
||||
number,
|
||||
primaryText,
|
||||
secondaryText,
|
||||
isSelected,
|
||||
buttonProps,
|
||||
}: EnvelopeItemSelectorProps) => {
|
||||
return (
|
||||
<button
|
||||
className={`flex min-w-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
isSelected
|
||||
? 'border-blue-200 bg-blue-50 text-blue-900'
|
||||
: 'border-gray-200 bg-gray-50 hover:bg-gray-100'
|
||||
}`}
|
||||
{...buttonProps}
|
||||
>
|
||||
<div
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
|
||||
isSelected ? 'bg-blue-100 text-blue-600' : 'bg-gray-200 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{number}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<div className="truncate text-sm font-medium">{primaryText}</div>
|
||||
<div className="text-xs text-gray-500">{secondaryText}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn('h-2 w-2 rounded-full', {
|
||||
'bg-blue-500': isSelected,
|
||||
})}
|
||||
></div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
type EnvelopeRendererFileSelectorProps = {
|
||||
fields: { envelopeItemId: string }[];
|
||||
className?: string;
|
||||
secondaryOverride?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeRendererFileSelector = ({
|
||||
fields,
|
||||
className,
|
||||
secondaryOverride,
|
||||
}: EnvelopeRendererFileSelectorProps) => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-fit space-x-2 overflow-x-auto p-4', className)}>
|
||||
{envelopeItems.map((doc, i) => (
|
||||
<EnvelopeItemSelector
|
||||
key={doc.id}
|
||||
number={i + 1}
|
||||
primaryText={doc.title}
|
||||
secondaryText={
|
||||
secondaryOverride ?? (
|
||||
<Plural
|
||||
one="1 Field"
|
||||
other="# Fields"
|
||||
value={fields.filter((field) => field.envelopeItemId === doc.id).length}
|
||||
/>
|
||||
)
|
||||
}
|
||||
isSelected={currentEnvelopeItem?.id === doc.id}
|
||||
buttonProps={{
|
||||
onClick: () => setCurrentEnvelopeItem(doc.id),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,181 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import Konva from 'konva';
|
||||
import type { Layer } from 'konva/lib/Layer';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
import { usePageContext } from 'react-pdf';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
|
||||
export default function EnvelopeGenericPageRenderer() {
|
||||
const pageContext = usePageContext();
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { _className, page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const { t } = useLingui();
|
||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Layer | null>(null);
|
||||
|
||||
const viewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
fields.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[fields, pageContext.pageNumber],
|
||||
);
|
||||
|
||||
// Custom renderer from Konva examples.
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport,
|
||||
};
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
createPageCanvas(container);
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, viewport],
|
||||
);
|
||||
|
||||
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
renderField({
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
customText: '',
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
// color: getRecipientColorKey(field.recipientId),
|
||||
color: 'purple', // Todo
|
||||
editable: false,
|
||||
mode: 'sign',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
stage.current?.add(pageLayer.current);
|
||||
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* Render fields when they are added or removed from the localFields.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!pageLayer.current || !stage.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If doesn't exist in localFields, destroy it since it's been deleted.
|
||||
pageLayer.current.find('Group').forEach((group) => {
|
||||
if (
|
||||
group.name() === 'field-group' &&
|
||||
!localPageFields.some((field) => field.id.toString() === group.id())
|
||||
) {
|
||||
console.log('Field removed, removing from canvas');
|
||||
group.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// If it exists, rerender.
|
||||
localPageFields.forEach((field) => {
|
||||
console.log('Field created/updated, rendering on canvas');
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields]);
|
||||
|
||||
if (!currentEnvelopeItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
|
||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
|
||||
export default function EnvelopeSignerForm() {
|
||||
const { fullName, signature, setFullName, setSignature, envelope, recipientFields } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
const hasSignatureField = useMemo(() => {
|
||||
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
}, [recipientFields]);
|
||||
|
||||
const isSubmitting = false;
|
||||
|
||||
return (
|
||||
<fieldset disabled={isSubmitting} className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
|
||||
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
|
||||
export const EnvelopeSignerHeader = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { envelope, setShowPendingFieldTooltip, recipientFieldsRemaining, recipient } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
mutateAsync: completeDocument,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const handleOnNextFieldClick = () => {
|
||||
const nextField = recipientFieldsRemaining[0];
|
||||
|
||||
if (!nextField) {
|
||||
setShowPendingFieldTooltip(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
|
||||
setCurrentEnvelopeItem(nextField.envelopeItemId);
|
||||
}
|
||||
|
||||
const fieldTooltip = document.querySelector(`#field-tooltip`);
|
||||
|
||||
if (fieldTooltip) {
|
||||
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
setShowPendingFieldTooltip(true);
|
||||
};
|
||||
|
||||
const handleOnCompleteClick = async (
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
) => {
|
||||
const payload = {
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
authOptions: accessAuthOptions,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
};
|
||||
|
||||
await completeDocument(payload);
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: envelope.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (envelope.documentMeta.redirectUrl) {
|
||||
window.location.href = envelope.documentMeta.redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="w-full border-b border-gray-200 bg-white px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/">
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="whitespace-nowrap text-sm font-medium text-gray-600">
|
||||
{envelope.title}
|
||||
</h1>
|
||||
|
||||
<Badge variant="secondary">
|
||||
<Trans>Approver</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
||||
<Plural
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
value={recipientFieldsRemaining.length}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isPending}
|
||||
onSignatureComplete={handleOnCompleteClick}
|
||||
documentTitle={envelope.title}
|
||||
fields={recipientFieldsRemaining}
|
||||
fieldsValidated={handleOnNextFieldClick}
|
||||
recipient={recipient}
|
||||
// Todo: Envelopes
|
||||
allowDictateNextSigner={envelope.documentMeta.allowDictateNextSigner}
|
||||
// defaultNextSigner={
|
||||
// nextRecipient
|
||||
// ? { name: nextRecipient.name, email: nextRecipient.email }
|
||||
// : undefined
|
||||
// }
|
||||
// Todo: Envelopes - use
|
||||
// buttonSize="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,417 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import type { Layer } from 'konva/lib/Layer';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
import { usePageContext } from 'react-pdf';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
|
||||
import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
|
||||
import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field';
|
||||
import { handleNameFieldClick } from '~/utils/field-signing/name-field';
|
||||
import { handleNumberFieldClick } from '~/utils/field-signing/number-field';
|
||||
import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field';
|
||||
import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
||||
|
||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
|
||||
export default function EnvelopeSignerPageRenderer() {
|
||||
const pageContext = usePageContext();
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { _className, page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
envelopeData,
|
||||
recipientFields,
|
||||
recipientFieldsRemaining,
|
||||
showPendingFieldTooltip,
|
||||
signField,
|
||||
email,
|
||||
setEmail,
|
||||
fullName,
|
||||
setFullName,
|
||||
signature,
|
||||
setSignature,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
console.log({ fullName });
|
||||
|
||||
const { envelope } = envelopeData;
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Layer | null>(null);
|
||||
|
||||
const viewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
recipientFields.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[recipientFields, pageContext.pageNumber],
|
||||
);
|
||||
|
||||
// Custom renderer from Konva examples.
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport,
|
||||
};
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
createPageCanvas(container);
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, viewport],
|
||||
);
|
||||
|
||||
const renderFieldOnLayer = (unparsedField: Field) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
|
||||
|
||||
let color: TRecipientColor = 'green';
|
||||
|
||||
if (fieldToRender.fieldMeta?.readOnly) {
|
||||
color = 'readOnly';
|
||||
} else if (showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)) {
|
||||
color = 'orange';
|
||||
}
|
||||
|
||||
const { fieldGroup } = renderField({
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: fieldToRender.id.toString(),
|
||||
...fieldToRender,
|
||||
width: Number(fieldToRender.width),
|
||||
height: Number(fieldToRender.height),
|
||||
positionX: Number(fieldToRender.positionX),
|
||||
positionY: Number(fieldToRender.positionY),
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
color,
|
||||
mode: 'sign',
|
||||
});
|
||||
|
||||
const handleFieldGroupClick = (e: KonvaEventObject<Event>) => {
|
||||
const currentTarget = e.currentTarget as Konva.Group;
|
||||
const target = e.target;
|
||||
|
||||
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
|
||||
|
||||
const foundField = recipientFields.find((f) => f.id === unparsedField.id);
|
||||
const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group');
|
||||
|
||||
if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingSpinnerGroup = createSpinner({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
});
|
||||
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
|
||||
const parsedFoundField = ZFullFieldSchema.parse(foundField);
|
||||
|
||||
match(parsedFoundField)
|
||||
/**
|
||||
* CHECKBOX FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.CHECKBOX }, (field) => {
|
||||
const { fieldMeta } = field;
|
||||
|
||||
const { values } = fieldMeta;
|
||||
|
||||
const checkedValues = (values || [])
|
||||
.map((v) => ({
|
||||
...v,
|
||||
checked: v.id === target.getAttr('internalCheckboxId') ? !v.checked : v.checked,
|
||||
}))
|
||||
.filter((v) => v.checked);
|
||||
|
||||
void signField(field.id, {
|
||||
type: FieldType.CHECKBOX,
|
||||
value: checkedValues.map((v) => v.id),
|
||||
}).finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* RADIO FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.RADIO }, (field) => {
|
||||
const { fieldMeta } = foundField;
|
||||
|
||||
const checkedValue = target.getAttr('internalRadioValue');
|
||||
|
||||
// Uncheck the value if it's already pressed.
|
||||
const value = field.inserted && checkedValue === field.customText ? null : checkedValue;
|
||||
|
||||
void signField(field.id, {
|
||||
type: FieldType.RADIO,
|
||||
value,
|
||||
}).finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* NUMBER FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.NUMBER }, (field) => {
|
||||
handleNumberFieldClick({ field, number: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* TEXT FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.TEXT }, (field) => {
|
||||
handleTextFieldClick({ field, text: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* EMAIL FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.EMAIL }, (field) => {
|
||||
handleEmailFieldClick({ field, email })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
await signField(field.id, payload); // Todo: Envelopes - Handle errors
|
||||
}
|
||||
|
||||
if (payload?.value) {
|
||||
setEmail(payload.value);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* INITIALS FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.INITIALS }, (field) => {
|
||||
const initials = fullName ? extractInitials(fullName) : null;
|
||||
|
||||
handleInitialsFieldClick({ field, initials })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* NAME FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.NAME }, (field) => {
|
||||
handleNameFieldClick({ field, name: fullName })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
|
||||
if (payload?.value) {
|
||||
setFullName(payload.value);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* DROPDOWN FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.DROPDOWN }, (field) => {
|
||||
handleDropdownFieldClick({ field, text: null })
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
|
||||
loadingSpinnerGroup.destroy();
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* DATE FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.DATE }, (field) => {
|
||||
void signField(field.id, {
|
||||
type: FieldType.DATE,
|
||||
value: !field.inserted,
|
||||
}).finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
/**
|
||||
* SIGNATURE FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.SIGNATURE }, (field) => {
|
||||
// Todo: Envelopes - Reauth
|
||||
handleSignatureFieldClick({
|
||||
field,
|
||||
signature,
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
||||
})
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
|
||||
if (payload?.value) {
|
||||
setSignature(payload.value);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loadingSpinnerGroup.destroy();
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
console.log('Field clicked');
|
||||
};
|
||||
|
||||
fieldGroup.off('click');
|
||||
fieldGroup.on('click', handleFieldGroupClick);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
stage.current?.add(pageLayer.current);
|
||||
|
||||
console.log({
|
||||
localPageFields,
|
||||
});
|
||||
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* Render fields when they are changed or inserted.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!pageLayer.current || !stage.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
localPageFields.forEach((field) => {
|
||||
console.log('Field changed/inserted, rendering on canvas');
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
|
||||
|
||||
if (!currentEnvelopeItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,6 +12,7 @@ import {
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -28,22 +29,15 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
export type FolderCardProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
onMove: (folder: TFolderWithSubfolders) => void;
|
||||
onPin: (folderId: string) => void;
|
||||
onUnpin: (folderId: string) => void;
|
||||
onSettings: (folder: TFolderWithSubfolders) => void;
|
||||
onDelete: (folder: TFolderWithSubfolders) => void;
|
||||
};
|
||||
|
||||
export const FolderCard = ({
|
||||
folder,
|
||||
onMove,
|
||||
onPin,
|
||||
onUnpin,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: FolderCardProps) => {
|
||||
export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const formatPath = () => {
|
||||
const rootPath =
|
||||
folder.type === FolderType.DOCUMENT
|
||||
@ -53,6 +47,15 @@ export const FolderCard = ({
|
||||
return `${rootPath}/f/${folder.id}`;
|
||||
};
|
||||
|
||||
const updateFolder = async ({ pinned }: { pinned: boolean }) => {
|
||||
await updateFolderMutation({
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
pinned,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
@ -112,9 +115,7 @@ export const FolderCard = ({
|
||||
<Trans>Move</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
|
||||
>
|
||||
<DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
|
||||
<PinIcon className="mr-2 h-4 w-4" />
|
||||
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -15,7 +15,7 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
||||
import { DocumentUploadButton } from '~/components/general/document/document-upload-button';
|
||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@ -34,9 +34,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
|
||||
type,
|
||||
parentId,
|
||||
@ -97,8 +94,11 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
{/* Todo: Envelopes - Feature flag */}
|
||||
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
|
||||
|
||||
{type === FolderType.DOCUMENT ? (
|
||||
<DocumentUploadDropzone />
|
||||
<DocumentUploadButton />
|
||||
) : (
|
||||
<TemplateCreateDialog folderId={parentId ?? undefined} />
|
||||
)}
|
||||
@ -152,8 +152,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@ -177,8 +175,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
|
||||
@ -15,6 +15,7 @@ export type ShareDocumentDownloadButtonProps = {
|
||||
documentData: DocumentData;
|
||||
};
|
||||
|
||||
// Todo: Envelopes - Support multiple item downloads.
|
||||
export const ShareDocumentDownloadButton = ({
|
||||
title,
|
||||
documentData,
|
||||
|
||||
@ -42,7 +42,7 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
||||
|
||||
const documentData = await putPdfFile(file);
|
||||
|
||||
const { id } = await createTemplate({
|
||||
const { legacyTemplateId: id } = await createTemplate({
|
||||
title: file.name,
|
||||
templateDocumentDataId: documentData.id,
|
||||
folderId: folderId ?? undefined,
|
||||
|
||||
@ -100,7 +100,7 @@ export const TemplateEditForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
|
||||
const { mutateAsync: addTemplateFields } = trpc.field.setFieldsForTemplate.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateById.setData(
|
||||
@ -193,7 +193,10 @@ export const TemplateEditForm = ({
|
||||
|
||||
setRecipients({
|
||||
templateId: template.id,
|
||||
recipients: data.signers,
|
||||
recipients: data.signers.map((signer) => ({
|
||||
...signer,
|
||||
id: signer.nativeId,
|
||||
})),
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -237,7 +240,11 @@ export const TemplateEditForm = ({
|
||||
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
|
||||
return addTemplateFields({
|
||||
templateId: template.id,
|
||||
fields: data.fields,
|
||||
fields: data.fields.map((field) => ({
|
||||
...field,
|
||||
id: field.nativeId,
|
||||
envelopeItemId: template.templateDocumentData.envelopeItemId,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -3,14 +3,17 @@ import { useMemo } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Template, User } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
|
||||
export type TemplatePageViewInformationProps = {
|
||||
userId: number;
|
||||
template: Template & {
|
||||
template: {
|
||||
userId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
};
|
||||
};
|
||||
|
||||
@ -113,7 +113,7 @@ export const TemplatePageViewRecentActivity = ({
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`${documentRootPath}/${document.id}`}
|
||||
to={`${documentRootPath}/${document.envelopeId}`}
|
||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||
>
|
||||
{match(document.source)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient, Template } from '@prisma/client';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { PenIcon, PlusIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
@ -11,20 +11,18 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
export type TemplatePageViewRecipientsProps = {
|
||||
template: Template & {
|
||||
recipients: Recipient[];
|
||||
};
|
||||
recipients: Recipient[];
|
||||
envelopeId: string;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
export const TemplatePageViewRecipients = ({
|
||||
template,
|
||||
recipients,
|
||||
envelopeId,
|
||||
templateRootPath,
|
||||
}: TemplatePageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const recipients = template.recipients;
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
@ -33,7 +31,7 @@ export const TemplatePageViewRecipients = ({
|
||||
</h1>
|
||||
|
||||
<Link
|
||||
to={`${templateRootPath}/${template.id}/edit?step=signers`}
|
||||
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
|
||||
title={_(msg`Modify recipients`)}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
|
||||
@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Document, Role, Subscription } from '@prisma/client';
|
||||
import type { Role, Subscription } from '@prisma/client';
|
||||
import { Edit, Loader } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
@ -20,7 +20,7 @@ type UserData = {
|
||||
email: string;
|
||||
roles: Role[];
|
||||
subscriptions?: SubscriptionLite[] | null;
|
||||
documents: DocumentLite[];
|
||||
documentCount: number;
|
||||
};
|
||||
|
||||
type SubscriptionLite = Pick<
|
||||
@ -28,8 +28,6 @@ type SubscriptionLite = Pick<
|
||||
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
|
||||
>;
|
||||
|
||||
type DocumentLite = Pick<Document, 'id'>;
|
||||
|
||||
type AdminDashboardUsersTableProps = {
|
||||
users: UserData[];
|
||||
totalPages: number;
|
||||
@ -74,10 +72,7 @@ export const AdminDashboardUsersTable = ({
|
||||
},
|
||||
{
|
||||
header: _(msg`Documents`),
|
||||
accessorKey: 'documents',
|
||||
cell: ({ row }) => {
|
||||
return <div>{row.original.documents?.length}</div>;
|
||||
},
|
||||
accessorKey: 'documentCount',
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
|
||||
136
apps/remix/app/components/tables/admin-document-jobs-table.tsx
Normal file
136
apps/remix/app/components/tables/admin-document-jobs-table.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
|
||||
export const AdminDocumentJobsTable = ({ envelopeId }: { envelopeId: string }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { data, isLoading, isLoadingError, refetch, isFetching } =
|
||||
trpc.admin.document.findJobs.useQuery({
|
||||
envelopeId: envelopeId,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
});
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Name`,
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: t`Status`,
|
||||
accessorKey: 'status',
|
||||
},
|
||||
{
|
||||
header: t`Submitted`,
|
||||
accessorKey: 'submittedAt',
|
||||
cell: ({ row }) => i18n.date(row.original.submittedAt),
|
||||
},
|
||||
{
|
||||
header: t`Retried`,
|
||||
accessorKey: 'retried',
|
||||
},
|
||||
{
|
||||
header: t`Last Retried`,
|
||||
accessorKey: 'lastRetriedAt',
|
||||
cell: ({ row }) =>
|
||||
row.original.lastRetriedAt ? i18n.date(row.original.lastRetriedAt) : 'N/A',
|
||||
},
|
||||
{
|
||||
header: t`Completed`,
|
||||
accessorKey: 'completedAt',
|
||||
cell: ({ row }) => (row.original.completedAt ? i18n.date(row.original.completedAt) : 'N/A'),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Background Jobs</Trans>
|
||||
</h2>
|
||||
|
||||
<Button variant="outline" size="sm" loading={isFetching} onClick={async () => refetch()}>
|
||||
<Trans>Reload</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell className="py-4 pr-4">
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) =>
|
||||
results.totalPages > 1 && (
|
||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||
)
|
||||
}
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -40,7 +40,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
const formatPath = `${documentsPath}/${row.id}/edit`;
|
||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
|
||||
@ -72,7 +72,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
const formatPath = `${documentsPath}/${row.id}/edit`;
|
||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -139,32 +139,35 @@ export const DocumentsTableActionDropdown = ({
|
||||
<Trans>Action</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
<>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
)}
|
||||
{!isDraft &&
|
||||
recipient &&
|
||||
recipient?.role !== RecipientRole.CC &&
|
||||
recipient?.role !== RecipientRole.ASSISTANT && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
<>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipient?.role === RecipientRole.SIGNER && (
|
||||
<>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Sign</Trans>
|
||||
</>
|
||||
)}
|
||||
{recipient?.role === RecipientRole.SIGNER && (
|
||||
<>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Sign</Trans>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipient?.role === RecipientRole.APPROVER && (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
<Trans>Approve</Trans>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{recipient?.role === RecipientRole.APPROVER && (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
<Trans>Approve</Trans>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||
<Link to={formatPath}>
|
||||
|
||||
@ -28,7 +28,7 @@ export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
})
|
||||
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
||||
<Link
|
||||
to={`${documentsPath}/${row.id}`}
|
||||
to={`${documentsPath}/${row.envelopeId}`}
|
||||
title={row.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
|
||||
@ -180,7 +180,7 @@ const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
||||
|
||||
const documentsPath = formatDocumentsPath(teamUrl);
|
||||
const formatPath = `${documentsPath}/${row.id}`;
|
||||
const formatPath = `${documentsPath}/${row.envelopeId}`;
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user