mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
fix: merge conflicts
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@ -56,4 +56,7 @@ logs.json
|
|||||||
|
|
||||||
# claude
|
# claude
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# agents
|
||||||
|
.specs
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
@ -7,18 +7,19 @@ import { addZeroMonth } from '../add-zero-month';
|
|||||||
|
|
||||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Document')
|
.selectFrom('Envelope')
|
||||||
.select(({ fn }) => [
|
.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.count('id').as('count'),
|
||||||
fn
|
fn
|
||||||
.sum(fn.count('id'))
|
.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
|
// 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
|
// 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'),
|
.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')
|
.groupBy('month')
|
||||||
.orderBy('month', 'desc')
|
.orderBy('month', 'desc')
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document } from '@prisma/client';
|
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type AdminDocumentDeleteDialogProps = {
|
export type AdminDocumentDeleteDialogProps = {
|
||||||
document: Document;
|
envelopeId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => {
|
export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -42,7 +41,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteDocument({ id: document.id, reason });
|
await deleteDocument({ id: envelopeId, reason });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document deleted`),
|
title: _(msg`Document deleted`),
|
||||||
|
|||||||
@ -57,14 +57,14 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicate.useMutation({
|
trpcReact.document.duplicate.useMutation({
|
||||||
onSuccess: async ({ documentId }) => {
|
onSuccess: async ({ id }) => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
description: _(msg`Your document has been successfully duplicated.`),
|
description: _(msg`Your document has been successfully duplicated.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`${documentsPath}/${documentId}/edit`);
|
await navigate(`${documentsPath}/${id}/edit`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -81,7 +81,7 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
|
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -94,9 +94,11 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
|
|
||||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await moveDocumentToFolder({
|
await updateDocument({
|
||||||
documentId,
|
documentId,
|
||||||
folderId: data.folderId ?? null,
|
data: {
|
||||||
|
folderId: data.folderId ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|||||||
@ -4,15 +4,15 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
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 { 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 { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -43,7 +43,11 @@ import { StackAvatar } from '../general/stack-avatar';
|
|||||||
const FORM_ID = 'resend-email';
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
export type DocumentResendDialogProps = {
|
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[];
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,14 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import type { Template, TemplateDirectLink } from '@prisma/client';
|
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
|
||||||
import { TemplateType } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
|
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type Template } from '@documenso/prisma/types/template-legacy-schema';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
|
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
|
||||||
@ -52,7 +52,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type ManagePublicTemplateDialogProps = {
|
export type ManagePublicTemplateDialogProps = {
|
||||||
directTemplates: (Template & {
|
directTemplates: (Omit<Template, 'templateDocumentDataId'> & {
|
||||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||||
})[];
|
})[];
|
||||||
initialTemplateId?: number | null;
|
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 [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
if (isUploadingFile) {
|
if (isUploadingFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -54,7 +56,7 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
|
|||||||
try {
|
try {
|
||||||
const response = await putPdfFile(file);
|
const response = await putPdfFile(file);
|
||||||
|
|
||||||
const { id } = await createTemplate({
|
const { legacyTemplateId: id } = await createTemplate({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
templateDocumentDataId: response.id,
|
templateDocumentDataId: response.id,
|
||||||
folderId: folderId,
|
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 { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
type Recipient,
|
CircleDotIcon,
|
||||||
RecipientRole,
|
CircleIcon,
|
||||||
type Template,
|
ClipboardCopyIcon,
|
||||||
type TemplateDirectLink,
|
InfoIcon,
|
||||||
} from '@prisma/client';
|
LinkIcon,
|
||||||
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
|
LoaderIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Link, useRevalidator } from 'react-router';
|
import { Link, useRevalidator } from 'react-router';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
@ -47,20 +50,19 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type TemplateDirectLinkDialogProps = {
|
type TemplateDirectLinkDialogProps = {
|
||||||
template: Template & {
|
templateId: number;
|
||||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
};
|
trigger?: React.ReactNode;
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
||||||
|
|
||||||
export const TemplateDirectLinkDialog = ({
|
export const TemplateDirectLinkDialog = ({
|
||||||
template,
|
templateId,
|
||||||
open,
|
directLink,
|
||||||
onOpenChange,
|
recipients,
|
||||||
|
trigger,
|
||||||
}: TemplateDirectLinkDialogProps) => {
|
}: TemplateDirectLinkDialogProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { quota, remaining } = useLimits();
|
const { quota, remaining } = useLimits();
|
||||||
@ -69,8 +71,9 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
|
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
|
|
||||||
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
|
const [open, setOpen] = useState(false);
|
||||||
const [token, setToken] = useState(template.directLink?.token ?? null);
|
const [isEnabled, setIsEnabled] = useState(directLink?.enabled ?? false);
|
||||||
|
const [token, setToken] = useState(directLink?.token ?? null);
|
||||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
|
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
|
||||||
token ? 'MANAGE' : 'ONBOARD',
|
token ? 'MANAGE' : 'ONBOARD',
|
||||||
@ -80,11 +83,11 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
|
|
||||||
const validDirectTemplateRecipients = useMemo(
|
const validDirectTemplateRecipients = useMemo(
|
||||||
() =>
|
() =>
|
||||||
template.recipients.filter(
|
recipients.filter(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||||
),
|
),
|
||||||
[template.recipients],
|
[recipients],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -140,7 +143,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await revalidate();
|
await revalidate();
|
||||||
|
|
||||||
onOpenChange(false);
|
setOpen(false);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -178,7 +181,7 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
setSelectedRecipientId(recipientId);
|
setSelectedRecipientId(recipientId);
|
||||||
|
|
||||||
await createTemplateDirectLink({
|
await createTemplateDirectLink({
|
||||||
templateId: template.id,
|
templateId,
|
||||||
directRecipientId: recipientId,
|
directRecipientId: recipientId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -195,300 +198,311 @@ export const TemplateDirectLinkDialog = ({
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||||
<fieldset disabled={isLoading} className="relative">
|
<DialogTrigger asChild>
|
||||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
{trigger || (
|
||||||
{match({ token, currentStep })
|
<Button variant="outline" className="px-3">
|
||||||
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create Direct Signing Link</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
{directLink ? <Trans>Manage Direct Link</Trans> : <Trans>Create Direct Link</Trans>}
|
||||||
<Trans>Here's how it works:</Trans>
|
</Button>
|
||||||
</DialogDescription>
|
)}
|
||||||
</DialogHeader>
|
</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">
|
<DialogDescription>
|
||||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
<Trans>Here's how it works:</Trans>
|
||||||
<li className="relative" key={index}>
|
</DialogDescription>
|
||||||
<div className="absolute -left-12">
|
</DialogHeader>
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
|
||||||
{index + 1}
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="font-semibold">{_(step.title)}</h3>
|
<h3 className="font-semibold">{_(step.title)}</h3>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{remaining.directTemplates === 0 && (
|
{remaining.directTemplates === 0 && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>
|
<Trans>
|
||||||
Direct template link usage exceeded ({quota.directTemplates}/
|
Direct template link usage exceeded ({quota.directTemplates}/
|
||||||
{quota.directTemplates})
|
{quota.directTemplates})
|
||||||
</Trans>
|
</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
You have reached the maximum limit of {quota.directTemplates} direct
|
You have reached the maximum limit of {quota.directTemplates} direct
|
||||||
templates.{' '}
|
templates.{' '}
|
||||||
<Link
|
<Link
|
||||||
className="mt-1 block underline underline-offset-4"
|
className="mt-1 block underline underline-offset-4"
|
||||||
to={`/o/${organisation.url}/settings/billing`}
|
to={`/o/${organisation.url}/settings/billing`}
|
||||||
>
|
>
|
||||||
Upgrade your account to continue!
|
Upgrade your account to continue!
|
||||||
</Link>
|
</Link>
|
||||||
</Trans>
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{remaining.directTemplates !== 0 && (
|
{remaining.directTemplates !== 0 && (
|
||||||
<DialogFooter className="mx-auto mt-4">
|
<DialogFooter className="mx-auto mt-4">
|
||||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||||
<Trans> Enable direct link signing</Trans>
|
<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>
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</DialogFooter>
|
)}
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
))
|
|
||||||
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
|
||||||
<DialogContent className="relative">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Direct Link Signing</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogHeader>
|
||||||
<Trans>Manage the direct link signing for this template</Trans>
|
<DialogTitle>
|
||||||
</DialogDescription>
|
<Trans>Choose Direct Link Recipient</Trans>
|
||||||
</DialogHeader>
|
</DialogTitle>
|
||||||
|
|
||||||
<div>
|
<DialogDescription>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<Trans>Choose an existing recipient from below to continue</Trans>
|
||||||
<Label className="flex flex-row">
|
</DialogDescription>
|
||||||
<Trans>Enable Direct Link Signing</Trans>
|
</DialogHeader>
|
||||||
<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
|
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
||||||
className="mt-2"
|
<Table>
|
||||||
checked={isEnabled}
|
<TableHeader>
|
||||||
onCheckedChange={(value) => setIsEnabled(value)}
|
<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>
|
||||||
|
|
||||||
<div className="mt-2">
|
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||||
<Label htmlFor="copy-direct-link">
|
{!recipients.some(
|
||||||
<Trans>Copy Shareable Link</Trans>
|
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||||
</Label>
|
) && (
|
||||||
|
<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
|
<Button
|
||||||
variant="none"
|
|
||||||
type="button"
|
type="button"
|
||||||
className="h-8 w-8"
|
className="mt-2"
|
||||||
onClick={() => void onCopyClick(token)}
|
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||||
|
onClick={async () =>
|
||||||
|
createTemplateDirectLink({
|
||||||
|
templateId,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
<Trans>Create one automatically</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="mr-auto w-full sm:w-auto"
|
className="mr-auto w-full sm:w-auto"
|
||||||
loading={isDeletingTemplateDirectLink}
|
loading={isDeletingTemplateDirectLink}
|
||||||
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
||||||
>
|
>
|
||||||
<Trans>Remove</Trans>
|
<Trans>Remove</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
loading={isTogglingTemplateAccess}
|
loading={isTogglingTemplateAccess}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await toggleTemplateDirectLink({
|
await toggleTemplateDirectLink({
|
||||||
templateId: template.id,
|
templateId,
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
onOpenChange(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
))
|
))
|
||||||
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
||||||
<DialogContent className="relative">
|
<DialogContent className="relative">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Are you sure?</Trans>
|
<Trans>Are you sure?</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
Please note that proceeding will remove direct linking recipient and turn it
|
Please note that proceeding will remove direct linking recipient and turn it
|
||||||
into a placeholder.
|
into a placeholder.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setCurrentStep('MANAGE')}
|
onClick={() => setCurrentStep('MANAGE')}
|
||||||
>
|
>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
loading={isDeletingTemplateDirectLink}
|
loading={isDeletingTemplateDirectLink}
|
||||||
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
|
onClick={() => void deleteTemplateDirectLink({ templateId })}
|
||||||
>
|
>
|
||||||
<Trans>Confirm</Trans>
|
<Trans>Confirm</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
))
|
))
|
||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
</AnimateGenericFadeInOut>
|
</AnimateGenericFadeInOut>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export function TemplateMoveToFolderDialog({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
|
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@ -96,9 +96,11 @@ export function TemplateMoveToFolderDialog({
|
|||||||
|
|
||||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await moveTemplateToFolder({
|
await updateTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
folderId: data.folderId ?? null,
|
data: {
|
||||||
|
folderId: data.folderId ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -384,6 +384,7 @@ export function TemplateUseDialog({
|
|||||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
htmlFor="useCustomDocument"
|
htmlFor="useCustomDocument"
|
||||||
>
|
>
|
||||||
|
{/* Todo: Envelopes - How will this work? */}
|
||||||
<Trans>Upload custom document</Trans>
|
<Trans>Upload custom document</Trans>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
|
||||||
import {
|
import {
|
||||||
ZDocumentMetaDateFormatSchema,
|
ZDocumentMetaDateFormatSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
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
|
// Define the schema for configuration
|
||||||
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
|
||||||
|
|||||||
@ -118,6 +118,7 @@ export const ConfigureFieldsView = ({
|
|||||||
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
sendStatus: signer.disabled ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
readStatus: signer.disabled ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
|
||||||
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
signingStatus: signer.disabled ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
|
envelopeId: '',
|
||||||
}));
|
}));
|
||||||
}, [configData.signers]);
|
}, [configData.signers]);
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@ -48,7 +48,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | null;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
allowWhiteLabelling?: boolean;
|
allowWhiteLabelling?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 { type Field, FieldType } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
|||||||
export type EmbedDocumentFieldsProps = {
|
export type EmbedDocumentFieldsProps = {
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: Pick<
|
metadata?: Pick<
|
||||||
DocumentMeta | TemplateMeta,
|
DocumentMeta,
|
||||||
| 'timezone'
|
| 'timezone'
|
||||||
| 'dateFormat'
|
| 'dateFormat'
|
||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
import type { DocumentMeta } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
type DocumentData,
|
type DocumentData,
|
||||||
type Field,
|
type Field,
|
||||||
@ -15,12 +15,14 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { 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 { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
@ -50,7 +52,7 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: DocumentField[];
|
completedFields: DocumentField[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
allowWhitelabelling?: boolean;
|
allowWhitelabelling?: boolean;
|
||||||
@ -275,7 +277,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
{allowDocumentRejection && (
|
{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">
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
document={{ id: documentId }}
|
documentId={documentId}
|
||||||
token={token}
|
token={token}
|
||||||
onRejected={onDocumentRejected}
|
onRejected={onDocumentRejected}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -212,7 +212,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
{allowDocumentRejection && (
|
{allowDocumentRejection && (
|
||||||
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
|
<div className="embed--Actions mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
document={document}
|
documentId={document.id}
|
||||||
token={token}
|
token={token}
|
||||||
onRejected={onRejected}
|
onRejected={onRejected}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -17,12 +17,12 @@ import {
|
|||||||
isValidLanguageCode,
|
isValidLanguageCode,
|
||||||
} from '@documenso/lib/constants/i18n';
|
} from '@documenso/lib/constants/i18n';
|
||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
|
||||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
|
||||||
import {
|
import {
|
||||||
type TDocumentMetaDateFormat,
|
type TDocumentMetaDateFormat,
|
||||||
ZDocumentMetaTimezoneSchema,
|
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 { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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">
|
<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
|
<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"
|
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" />
|
<BrandingLogo className="h-6 w-auto" />
|
||||||
|
|||||||
@ -55,7 +55,7 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
|
|||||||
|
|
||||||
export type DirectTemplateSigningFormProps = {
|
export type DirectTemplateSigningFormProps = {
|
||||||
flowStep: DocumentFlowStep;
|
flowStep: DocumentFlowStep;
|
||||||
directRecipient: Recipient;
|
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
|
||||||
directRecipientFields: Field[];
|
directRecipientFields: Field[];
|
||||||
template: Omit<TTemplate, 'user'>;
|
template: Omit<TTemplate, 'user'>;
|
||||||
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
||||||
@ -417,11 +417,11 @@ export const DirectTemplateSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit}
|
onSignatureComplete={async () => handleSubmit()}
|
||||||
documentTitle={template.title}
|
documentTitle={template.title}
|
||||||
fields={localFields}
|
fields={localFields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={directRecipient.role}
|
recipient={directRecipient}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerFooter>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
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 type { SessionUser } from '@documenso/auth/server/lib/session/session';
|
||||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
@ -24,14 +24,16 @@ type PasskeyData = {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
|
||||||
|
|
||||||
export type DocumentSigningAuthContextValue = {
|
export type DocumentSigningAuthContextValue = {
|
||||||
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||||
documentAuthOptions: Document['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
documentAuthOption: TDocumentAuthOptions;
|
documentAuthOption: TDocumentAuthOptions;
|
||||||
setDocumentAuthOptions: (_value: Document['authOptions']) => void;
|
setDocumentAuthOptions: (_value: Envelope['authOptions']) => void;
|
||||||
recipient: Recipient;
|
recipient: SigningAuthRecipient;
|
||||||
recipientAuthOption: TRecipientAuthOptions;
|
recipientAuthOption: TRecipientAuthOptions;
|
||||||
setRecipient: (_value: Recipient) => void;
|
setRecipient: (_value: SigningAuthRecipient) => void;
|
||||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||||
isAuthRedirectRequired: boolean;
|
isAuthRedirectRequired: boolean;
|
||||||
@ -61,8 +63,8 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface DocumentSigningAuthProviderProps {
|
export interface DocumentSigningAuthProviderProps {
|
||||||
documentAuthOptions: Document['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
recipient: Recipient;
|
recipient: SigningAuthRecipient;
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,17 @@ import { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
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 { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -27,15 +32,21 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
|
|
||||||
export type DocumentSigningCompleteDialogProps = {
|
export type DocumentSigningCompleteDialogProps = {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
onSignatureComplete: (
|
||||||
role: RecipientRole;
|
nextSigner?: { name: string; email: string },
|
||||||
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
allowDictateNextSigner?: boolean;
|
allowDictateNextSigner?: boolean;
|
||||||
defaultNextSigner?: {
|
defaultNextSigner?: {
|
||||||
@ -47,6 +58,7 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
const ZNextSignerFormSchema = z.object({
|
const ZNextSignerFormSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
email: z.string().email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
|
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
@ -57,7 +69,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
fields,
|
fields,
|
||||||
fieldsValidated,
|
fieldsValidated,
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
recipient,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
allowDictateNextSigner = false,
|
allowDictateNextSigner = false,
|
||||||
defaultNextSigner,
|
defaultNextSigner,
|
||||||
@ -65,6 +77,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [isEditingNextSigner, setIsEditingNextSigner] = 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>({
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -75,6 +92,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
|
|
||||||
|
const completionRequires2FA = useMemo(
|
||||||
|
() => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'),
|
||||||
|
[derivedRecipientAccessAuth],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (form.formState.isSubmitting || !isComplete) {
|
if (form.formState.isSubmitting || !isComplete) {
|
||||||
return;
|
return;
|
||||||
@ -93,16 +115,43 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
try {
|
try {
|
||||||
if (allowDictateNextSigner && data.name && data.email) {
|
// Check if 2FA is required
|
||||||
await onSignatureComplete({ name: data.name, email: data.email });
|
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||||
} else {
|
setShowTwoFactorForm(true);
|
||||||
await onSignatureComplete();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextSigner =
|
||||||
|
allowDictateNextSigner && data.name && data.email
|
||||||
|
? { name: data.name, email: data.email }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await onSignatureComplete(nextSigner, data.accessAuthOptions);
|
||||||
} catch (error) {
|
} 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'));
|
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -116,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{match({ isComplete, role })
|
{match({ isComplete, role: recipient.role })
|
||||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
||||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||||
@ -128,184 +177,194 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Form {...form}>
|
{!showTwoFactorForm && (
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
<Form {...form}>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<DialogTitle>
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<DialogTitle>
|
||||||
{match(role)
|
<div className="text-foreground text-xl font-semibold">
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||||
.exhaustive()}
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||||
</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>)
|
|
||||||
.exhaustive()}
|
.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>
|
</div>
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
{allowDictateNextSigner && (
|
||||||
</form>
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
</Form>
|
{!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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Controller, useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
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 { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
@ -34,10 +34,10 @@ export type DocumentSigningFormProps = {
|
|||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
setSelectedSignerId?: (id: number | null) => void;
|
||||||
completeDocument: (
|
completeDocument: (options: {
|
||||||
authOptions?: TRecipientActionAuth,
|
accessAuthOptions?: TRecipientAccessAuth;
|
||||||
nextSigner?: { email: string; name: string },
|
nextSigner?: { email: string; name: string };
|
||||||
) => Promise<void>;
|
}) => Promise<void>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
fieldsValidated: () => void;
|
fieldsValidated: () => void;
|
||||||
nextRecipient?: RecipientWithFields;
|
nextRecipient?: RecipientWithFields;
|
||||||
@ -105,7 +105,7 @@ export const DocumentSigningForm = ({
|
|||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument(undefined, nextSigner);
|
await completeDocument({ nextSigner });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@ -149,10 +149,10 @@ export const DocumentSigningForm = ({
|
|||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={localFieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({ nextSigner, accessAuthOptions })
|
||||||
}}
|
}
|
||||||
role={recipient.role}
|
recipient={recipient}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
defaultNextSigner={
|
defaultNextSigner={
|
||||||
nextRecipient
|
nextRecipient
|
||||||
@ -309,10 +309,13 @@ export const DocumentSigningForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={localFieldsValidated}
|
fieldsValidated={localFieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner, accessAuthOptions) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({
|
||||||
}}
|
accessAuthOptions,
|
||||||
role={recipient.role}
|
nextSigner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
recipient={recipient}
|
||||||
allowDictateNextSigner={
|
allowDictateNextSigner={
|
||||||
nextRecipient && document.documentMeta?.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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
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 {
|
import {
|
||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
ZDropdownFieldMeta,
|
ZDropdownFieldMeta,
|
||||||
@ -46,10 +46,11 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi
|
|||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||||
|
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type DocumentSigningPageViewProps = {
|
export type DocumentSigningPageViewV1Props = {
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
@ -59,7 +60,7 @@ export type DocumentSigningPageViewProps = {
|
|||||||
includeSenderDetails: boolean;
|
includeSenderDetails: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageViewV1 = ({
|
||||||
recipient,
|
recipient,
|
||||||
document,
|
document,
|
||||||
fields,
|
fields,
|
||||||
@ -67,9 +68,15 @@ export const DocumentSigningPageView = ({
|
|||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
}: DocumentSigningPageViewProps) => {
|
}: DocumentSigningPageViewV1Props) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
|
const hasAuthenticator = authUser?.twoFactorEnabled
|
||||||
|
? authUser.twoFactorEnabled && authUser.email === recipient.email
|
||||||
|
: false;
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
@ -94,14 +101,16 @@ export const DocumentSigningPageView = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (
|
const completeDocument = async (options: {
|
||||||
authOptions?: TRecipientActionAuth,
|
accessAuthOptions?: TRecipientAccessAuth;
|
||||||
nextSigner?: { email: string; name: string },
|
nextSigner?: { email: string; name: string };
|
||||||
) => {
|
}) => {
|
||||||
|
const { accessAuthOptions, nextSigner } = options;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
authOptions,
|
accessAuthOptions,
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -222,7 +231,7 @@ export const DocumentSigningPageView = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentSigningRejectDialog document={document} 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">
|
<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 +274,10 @@ export const DocumentSigningPageView = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) =>
|
||||||
await completeDocument(undefined, nextSigner);
|
completeDocument({ nextSigner })
|
||||||
}}
|
}
|
||||||
role={recipient.role}
|
recipient={recipient}
|
||||||
allowDictateNextSigner={
|
allowDictateNextSigner={
|
||||||
nextRecipient && documentMeta?.allowDictateNextSigner
|
nextRecipient && documentMeta?.allowDictateNextSigner
|
||||||
}
|
}
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
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 { 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, 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>
|
||||||
|
|
||||||
|
{/* 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 regular mode, this is the actual signer.
|
||||||
* In assistant mode, this is the recipient who is helping fill out the document.
|
* 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.
|
* Only present in assistant mode.
|
||||||
@ -29,7 +32,10 @@ const DocumentSigningRecipientContext = createContext<DocumentSigningRecipientCo
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
|
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
|
||||||
recipient: Recipient | RecipientWithFields;
|
recipient: Pick<
|
||||||
|
Recipient | RecipientWithFields,
|
||||||
|
'name' | 'email' | 'token' | 'role' | 'authOptions'
|
||||||
|
>;
|
||||||
targetSigner?: RecipientWithFields | null;
|
targetSigner?: RecipientWithFields | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@ -37,13 +36,13 @@ const ZRejectDocumentFormSchema = z.object({
|
|||||||
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||||
|
|
||||||
export interface DocumentSigningRejectDialogProps {
|
export interface DocumentSigningRejectDialogProps {
|
||||||
document: Pick<Document, 'id'>;
|
documentId: number;
|
||||||
token: string;
|
token: string;
|
||||||
onRejected?: (reason: string) => void | Promise<void>;
|
onRejected?: (reason: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentSigningRejectDialog({
|
export function DocumentSigningRejectDialog({
|
||||||
document,
|
documentId,
|
||||||
token,
|
token,
|
||||||
onRejected,
|
onRejected,
|
||||||
}: DocumentSigningRejectDialogProps) {
|
}: DocumentSigningRejectDialogProps) {
|
||||||
@ -66,7 +65,7 @@ export function DocumentSigningRejectDialog({
|
|||||||
const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => {
|
const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await rejectDocumentWithToken({
|
await rejectDocumentWithToken({
|
||||||
documentId: document.id,
|
documentId,
|
||||||
token,
|
token,
|
||||||
reason,
|
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';
|
||||||
@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentData } from '@prisma/client';
|
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -16,13 +19,16 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
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';
|
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
||||||
|
|
||||||
export type DocumentCertificateQRViewProps = {
|
export type DocumentCertificateQRViewProps = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
title: string;
|
title: string;
|
||||||
documentData: DocumentData;
|
internalVersion: number;
|
||||||
password?: string | null;
|
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[];
|
||||||
|
documentTeamUrl: string;
|
||||||
recipientCount?: number;
|
recipientCount?: number;
|
||||||
completedDate?: Date;
|
completedDate?: Date;
|
||||||
};
|
};
|
||||||
@ -30,31 +36,32 @@ export type DocumentCertificateQRViewProps = {
|
|||||||
export const DocumentCertificateQRView = ({
|
export const DocumentCertificateQRView = ({
|
||||||
documentId,
|
documentId,
|
||||||
title,
|
title,
|
||||||
documentData,
|
internalVersion,
|
||||||
password,
|
envelopeItems,
|
||||||
|
documentTeamUrl,
|
||||||
recipientCount = 0,
|
recipientCount = 0,
|
||||||
completedDate,
|
completedDate,
|
||||||
}: DocumentCertificateQRViewProps) => {
|
}: DocumentCertificateQRViewProps) => {
|
||||||
const { data: documentUrl } = trpc.shareLink.getDocumentInternalUrlForQRCode.useQuery({
|
const { data: documentViaUser } = trpc.document.get.useQuery({
|
||||||
documentId,
|
documentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentUrl);
|
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser);
|
||||||
|
|
||||||
const formattedDate = completedDate
|
const formattedDate = completedDate
|
||||||
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
|
? DateTime.fromJSDate(completedDate).toLocaleString(DateTime.DATETIME_MED)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (documentUrl) {
|
if (documentViaUser) {
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
}
|
}
|
||||||
}, [documentUrl]);
|
}, [documentViaUser]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-md">
|
<div className="mx-auto w-full max-w-screen-md">
|
||||||
{/* Dialog for internal document link */}
|
{/* Dialog for internal document link */}
|
||||||
{documentUrl && (
|
{documentViaUser && (
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -72,7 +79,11 @@ export const DocumentCertificateQRView = ({
|
|||||||
|
|
||||||
<DialogFooter className="flex flex-row justify-end gap-2">
|
<DialogFooter className="flex flex-row justify-end gap-2">
|
||||||
<Button asChild>
|
<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>
|
<Trans>Go to document</Trans>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
@ -95,11 +106,21 @@ export const DocumentCertificateQRView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShareDocumentDownloadButton title={title} documentData={documentData} />
|
<ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
|||||||
|
|
||||||
const response = await putPdfFile(file);
|
const response = await putPdfFile(file);
|
||||||
|
|
||||||
const { id } = await createDocument({
|
const { legacyDocumentId: id } = await createDocument({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId: response.id,
|
documentDataId: response.id,
|
||||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
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,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: ({ fields: newFields }) => {
|
onSuccess: ({ fields: newFields }) => {
|
||||||
utils.document.get.setData(
|
utils.document.get.setData(
|
||||||
@ -230,6 +230,7 @@ export const DocumentEditForm = ({
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipients: data.signers.map((signer) => ({
|
recipients: data.signers.map((signer) => ({
|
||||||
...signer,
|
...signer,
|
||||||
|
id: signer.nativeId,
|
||||||
// Explicitly set to null to indicate we want to remove auth if required.
|
// Explicitly set to null to indicate we want to remove auth if required.
|
||||||
actionAuth: signer.actionAuth ?? [],
|
actionAuth: signer.actionAuth ?? [],
|
||||||
})),
|
})),
|
||||||
@ -253,6 +254,7 @@ export const DocumentEditForm = ({
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipients: data.signers.map((signer) => ({
|
recipients: data.signers.map((signer) => ({
|
||||||
...signer,
|
...signer,
|
||||||
|
id: signer.nativeId,
|
||||||
// Explicitly set to null to indicate we want to remove auth if required.
|
// Explicitly set to null to indicate we want to remove auth if required.
|
||||||
actionAuth: signer.actionAuth ?? [],
|
actionAuth: signer.actionAuth ?? [],
|
||||||
})),
|
})),
|
||||||
@ -292,7 +294,11 @@ export const DocumentEditForm = ({
|
|||||||
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
|
||||||
return addFields({
|
return addFields({
|
||||||
documentId: document.id,
|
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,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await navigate(`${documentRootPath}/${document.id}`);
|
await navigate(`${documentRootPath}/${document.envelopeId}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@ -9,57 +8,43 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DocumentPageViewButtonProps = {
|
export type DocumentPageViewButtonProps = {
|
||||||
document: Document & {
|
envelope: TEnvelope;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'>;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
|
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
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 isRecipient = !!recipient;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = envelope.status === DocumentStatus.PENDING;
|
||||||
const isComplete = isDocumentCompleted(document);
|
const isComplete = isDocumentCompleted(envelope);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(document.team.url);
|
const documentsPath = formatDocumentsPath(envelope.team.url);
|
||||||
const formatPath = `${documentsPath}/${document.id}/edit`;
|
const formatPath = `${documentsPath}/${envelope.id}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
// Todo; Envelopes - Support multiple items
|
||||||
{
|
const envelopeItem = envelope.envelopeItems[0];
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: document.team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
if (!envelopeItem.documentData) {
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw new Error('No document available');
|
throw new Error('No document available');
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: documentWithData.title });
|
await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, Team, User } from '@prisma/client';
|
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
@ -19,7 +18,9 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@ -39,14 +40,10 @@ import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/d
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentPageViewDropdownProps = {
|
export type DocumentPageViewDropdownProps = {
|
||||||
document: Document & {
|
envelope: TEnvelope;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownProps) => {
|
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@ -57,14 +54,14 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = 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 isOwner = envelope.userId === user.id;
|
||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
const isDraft = envelope.status === DocumentStatus.DRAFT;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = envelope.status === DocumentStatus.PENDING;
|
||||||
const isDeleted = document.deletedAt !== null;
|
const isDeleted = envelope.deletedAt !== null;
|
||||||
const isComplete = isDocumentCompleted(document);
|
const isComplete = isDocumentCompleted(envelope);
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && envelope.teamId === team.id;
|
||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
@ -73,7 +70,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
try {
|
try {
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
const documentWithData = await trpcClient.document.get.query(
|
||||||
{
|
{
|
||||||
documentId: document.id,
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
context: {
|
context: {
|
||||||
@ -88,7 +85,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: document.title });
|
await downloadPDF({ documentData, fileName: envelope.title });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -102,7 +99,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
try {
|
try {
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
const documentWithData = await trpcClient.document.get.query(
|
||||||
{
|
{
|
||||||
documentId: document.id,
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
context: {
|
context: {
|
||||||
@ -117,7 +114,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: document.title, version: 'original' });
|
await downloadPDF({ documentData, fileName: envelope.title, version: 'original' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
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 (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -142,7 +139,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${document.id}/edit`}>
|
<Link to={`${documentsPath}/${envelope.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
@ -162,7 +159,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${document.id}/logs`}>
|
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Audit Logs</Trans>
|
<Trans>Audit Logs</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
@ -184,7 +181,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{canManageDocument && (
|
{canManageDocument && (
|
||||||
<DocumentRecipientLinkCopyDialog
|
<DocumentRecipientLinkCopyDialog
|
||||||
recipients={document.recipients}
|
recipients={envelope.recipients}
|
||||||
trigger={
|
trigger={
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!isPending || isDeleted}
|
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
|
<DocumentShareButton
|
||||||
documentId={document.id}
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
token={isOwner ? undefined : recipient?.token}
|
token={isOwner ? undefined : recipient?.token}
|
||||||
trigger={({ loading, disabled }) => (
|
trigger={({ loading, disabled }) => (
|
||||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||||
@ -214,9 +217,9 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
<DocumentDeleteDialog
|
<DocumentDeleteDialog
|
||||||
id={document.id}
|
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
status={document.status}
|
status={envelope.status}
|
||||||
documentTitle={document.title}
|
documentTitle={envelope.title}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
canManageDocument={canManageDocument}
|
canManageDocument={canManageDocument}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
@ -227,7 +230,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={document.id}
|
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,21 +3,18 @@ import { useMemo } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Document, Recipient, User } from '@prisma/client';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
|
|
||||||
export type DocumentPageViewInformationProps = {
|
export type DocumentPageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
document: Document & {
|
envelope: TEnvelope;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
|
||||||
recipients: Recipient[];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewInformation = ({
|
export const DocumentPageViewInformation = ({
|
||||||
document,
|
envelope,
|
||||||
userId,
|
userId,
|
||||||
}: DocumentPageViewInformationProps) => {
|
}: DocumentPageViewInformationProps) => {
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
@ -29,23 +26,23 @@ export const DocumentPageViewInformation = ({
|
|||||||
{
|
{
|
||||||
description: msg`Uploaded by`,
|
description: msg`Uploaded by`,
|
||||||
value:
|
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`,
|
description: msg`Created`,
|
||||||
value: DateTime.fromJSDate(document.createdAt)
|
value: DateTime.fromJSDate(envelope.createdAt)
|
||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toFormat('MMMM d, yyyy'),
|
.toFormat('MMMM d, yyyy'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: msg`Last modified`,
|
description: msg`Last modified`,
|
||||||
value: DateTime.fromJSDate(document.updatedAt)
|
value: DateTime.fromJSDate(envelope.updatedAt)
|
||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toRelative(),
|
.toRelative(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, document, userId]);
|
}, [isMounted, envelope, userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
<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 { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import type { Document, Recipient } from '@prisma/client';
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@ -17,6 +16,7 @@ import { Link } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||||
@ -27,20 +27,18 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DocumentPageViewRecipientsProps = {
|
export type DocumentPageViewRecipientsProps = {
|
||||||
document: Document & {
|
envelope: TEnvelope;
|
||||||
recipients: Recipient[];
|
|
||||||
};
|
|
||||||
documentRootPath: string;
|
documentRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewRecipients = ({
|
export const DocumentPageViewRecipients = ({
|
||||||
document,
|
envelope,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: DocumentPageViewRecipientsProps) => {
|
}: DocumentPageViewRecipientsProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const recipients = document.recipients;
|
const recipients = envelope.recipients;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
@ -49,9 +47,9 @@ export const DocumentPageViewRecipients = ({
|
|||||||
<Trans>Recipients</Trans>
|
<Trans>Recipients</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{!isDocumentCompleted(document.status) && (
|
{!isDocumentCompleted(envelope.status) && (
|
||||||
<Link
|
<Link
|
||||||
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
to={`${documentRootPath}/${envelope.id}/edit?step=signers`}
|
||||||
title={_(msg`Modify recipients`)}
|
title={_(msg`Modify recipients`)}
|
||||||
className="flex flex-row items-center justify-between"
|
className="flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
@ -84,7 +82,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
{envelope.status !== DocumentStatus.DRAFT &&
|
||||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||||
<Badge variant="default">
|
<Badge variant="default">
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
@ -95,7 +93,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.CC, () =>
|
.with(RecipientRole.CC, () =>
|
||||||
document.status === DocumentStatus.COMPLETED ? (
|
envelope.status === DocumentStatus.COMPLETED ? (
|
||||||
<>
|
<>
|
||||||
<MailIcon className="mr-1 h-3 w-3" />
|
<MailIcon className="mr-1 h-3 w-3" />
|
||||||
<Trans>Sent</Trans>
|
<Trans>Sent</Trans>
|
||||||
@ -130,7 +128,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
{envelope.status !== DocumentStatus.DRAFT &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
@ -138,7 +136,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
{envelope.status !== DocumentStatus.DRAFT &&
|
||||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||||
<PopoverHover
|
<PopoverHover
|
||||||
trigger={
|
trigger={
|
||||||
@ -158,7 +156,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</PopoverHover>
|
</PopoverHover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status === DocumentStatus.PENDING &&
|
{envelope.status === DocumentStatus.PENDING &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||||
recipient.role !== RecipientRole.CC && (
|
recipient.role !== RecipientRole.CC && (
|
||||||
<CopyTextButton
|
<CopyTextButton
|
||||||
|
|||||||
@ -28,11 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentUploadDropzoneProps = {
|
export type DocumentUploadButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProps) => {
|
export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
@ -75,10 +75,10 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
|
|
||||||
const response = await putPdfFile(file);
|
const response = await putPdfFile(file);
|
||||||
|
|
||||||
const { id } = await createDocument({
|
const { legacyDocumentId: id } = await createDocument({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId: response.id,
|
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,
|
folderId: folderId ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -140,7 +140,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={async (files) => onFileDrop(files[0])}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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,183 @@
|
|||||||
|
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 { 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">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
|||||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
||||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-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 { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
@ -97,8 +97,11 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||||
|
{/* Todo: Envelopes - Feature flag */}
|
||||||
|
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
|
||||||
|
|
||||||
{type === FolderType.DOCUMENT ? (
|
{type === FolderType.DOCUMENT ? (
|
||||||
<DocumentUploadDropzone />
|
<DocumentUploadButton />
|
||||||
) : (
|
) : (
|
||||||
<TemplateCreateDialog folderId={parentId ?? undefined} />
|
<TemplateCreateDialog folderId={parentId ?? undefined} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export type ShareDocumentDownloadButtonProps = {
|
|||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Todo: Envelopes - Support multiple item downloads.
|
||||||
export const ShareDocumentDownloadButton = ({
|
export const ShareDocumentDownloadButton = ({
|
||||||
title,
|
title,
|
||||||
documentData,
|
documentData,
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
|||||||
|
|
||||||
const documentData = await putPdfFile(file);
|
const documentData = await putPdfFile(file);
|
||||||
|
|
||||||
const { id } = await createTemplate({
|
const { legacyTemplateId: id } = await createTemplate({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
templateDocumentDataId: documentData.id,
|
templateDocumentDataId: documentData.id,
|
||||||
folderId: folderId ?? undefined,
|
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,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.template.getTemplateById.setData(
|
utils.template.getTemplateById.setData(
|
||||||
@ -193,7 +193,10 @@ export const TemplateEditForm = ({
|
|||||||
|
|
||||||
setRecipients({
|
setRecipients({
|
||||||
templateId: template.id,
|
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) => {
|
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
|
||||||
return addTemplateFields({
|
return addTemplateFields({
|
||||||
templateId: template.id,
|
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 { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Template, User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
|
||||||
export type TemplatePageViewInformationProps = {
|
export type TemplatePageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
template: Template & {
|
template: {
|
||||||
|
userId: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -113,7 +113,7 @@ export const TemplatePageViewRecentActivity = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<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"
|
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||||
>
|
>
|
||||||
{match(document.source)
|
{match(document.source)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Recipient, Template } from '@prisma/client';
|
import type { Recipient } from '@prisma/client';
|
||||||
import { PenIcon, PlusIcon } from 'lucide-react';
|
import { PenIcon, PlusIcon } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
@ -11,20 +11,18 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
export type TemplatePageViewRecipientsProps = {
|
export type TemplatePageViewRecipientsProps = {
|
||||||
template: Template & {
|
recipients: Recipient[];
|
||||||
recipients: Recipient[];
|
envelopeId: string;
|
||||||
};
|
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplatePageViewRecipients = ({
|
export const TemplatePageViewRecipients = ({
|
||||||
template,
|
recipients,
|
||||||
|
envelopeId,
|
||||||
templateRootPath,
|
templateRootPath,
|
||||||
}: TemplatePageViewRecipientsProps) => {
|
}: TemplatePageViewRecipientsProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const recipients = template.recipients;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||||
@ -33,7 +31,7 @@ export const TemplatePageViewRecipients = ({
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to={`${templateRootPath}/${template.id}/edit?step=signers`}
|
to={`${templateRootPath}/${envelopeId}/edit?step=signers`}
|
||||||
title={_(msg`Modify recipients`)}
|
title={_(msg`Modify recipients`)}
|
||||||
className="flex flex-row items-center justify-between"
|
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 { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
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 { Edit, Loader } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ type UserData = {
|
|||||||
email: string;
|
email: string;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
subscriptions?: SubscriptionLite[] | null;
|
subscriptions?: SubscriptionLite[] | null;
|
||||||
documents: DocumentLite[];
|
documentCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SubscriptionLite = Pick<
|
type SubscriptionLite = Pick<
|
||||||
@ -28,8 +28,6 @@ type SubscriptionLite = Pick<
|
|||||||
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
|
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type DocumentLite = Pick<Document, 'id'>;
|
|
||||||
|
|
||||||
type AdminDashboardUsersTableProps = {
|
type AdminDashboardUsersTableProps = {
|
||||||
users: UserData[];
|
users: UserData[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@ -74,10 +72,7 @@ export const AdminDashboardUsersTable = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`Documents`),
|
header: _(msg`Documents`),
|
||||||
accessorKey: 'documents',
|
accessorKey: 'documentCount',
|
||||||
cell: ({ row }) => {
|
|
||||||
return <div>{row.original.documents?.length}</div>;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '',
|
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 isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
const formatPath = `${documentsPath}/${row.id}/edit`;
|
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
const formatPath = `${documentsPath}/${row.id}/edit`;
|
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -139,32 +139,35 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
<Trans>Action</Trans>
|
<Trans>Action</Trans>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
{!isDraft &&
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
recipient &&
|
||||||
<Link to={`/sign/${recipient?.token}`}>
|
recipient?.role !== RecipientRole.CC &&
|
||||||
{recipient?.role === RecipientRole.VIEWER && (
|
recipient?.role !== RecipientRole.ASSISTANT && (
|
||||||
<>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<EyeIcon className="mr-2 h-4 w-4" />
|
<Link to={`/sign/${recipient?.token}`}>
|
||||||
<Trans>View</Trans>
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
</>
|
<>
|
||||||
)}
|
<EyeIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>View</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{recipient?.role === RecipientRole.SIGNER && (
|
{recipient?.role === RecipientRole.SIGNER && (
|
||||||
<>
|
<>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
<Trans>Sign</Trans>
|
<Trans>Sign</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipient?.role === RecipientRole.APPROVER && (
|
{recipient?.role === RecipientRole.APPROVER && (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
<Trans>Approve</Trans>
|
<Trans>Approve</Trans>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||||
<Link to={formatPath}>
|
<Link to={formatPath}>
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
|||||||
})
|
})
|
||||||
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
||||||
<Link
|
<Link
|
||||||
to={`${documentsPath}/${row.id}`}
|
to={`${documentsPath}/${row.envelopeId}`}
|
||||||
title={row.title}
|
title={row.title}
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
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 isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(teamUrl);
|
const documentsPath = formatDocumentsPath(teamUrl);
|
||||||
const formatPath = `${documentsPath}/${row.id}`;
|
const formatPath = `${documentsPath}/${row.envelopeId}`;
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
|
|||||||
@ -3,8 +3,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { TemplateDirectLink } from '@prisma/client';
|
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
|
||||||
import { TemplateType } from '@prisma/client';
|
|
||||||
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
|
import type { Recipient, TemplateDirectLink } from '@prisma/client';
|
||||||
import { Copy, Edit, FolderIcon, MoreHorizontal, Share2Icon, Trash2, Upload } from 'lucide-react';
|
import { Copy, Edit, FolderIcon, MoreHorizontal, Share2Icon, Trash2, Upload } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
@ -21,7 +21,13 @@ import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
|
|||||||
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
|
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
|
||||||
|
|
||||||
export type TemplatesTableActionDropdownProps = {
|
export type TemplatesTableActionDropdownProps = {
|
||||||
row: Template & {
|
row: {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
title: string;
|
||||||
|
folderId?: string | null;
|
||||||
|
envelopeId: string;
|
||||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
};
|
};
|
||||||
@ -39,14 +45,13 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
|
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
|
||||||
|
|
||||||
const isOwner = row.userId === user.id;
|
const isOwner = row.userId === user.id;
|
||||||
const isTeamTemplate = row.teamId === teamId;
|
const isTeamTemplate = row.teamId === teamId;
|
||||||
|
|
||||||
const formatPath = `${templateRootPath}/${row.id}/edit`;
|
const formatPath = `${templateRootPath}/${row.envelopeId}/edit`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -72,10 +77,20 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
<Trans>Duplicate</Trans>
|
<Trans>Duplicate</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
|
<TemplateDirectLinkDialog
|
||||||
<Share2Icon className="mr-2 h-4 w-4" />
|
templateId={row.id}
|
||||||
<Trans>Direct link</Trans>
|
recipients={row.recipients}
|
||||||
</DropdownMenuItem>
|
directLink={row.directLink}
|
||||||
|
trigger={
|
||||||
|
<div
|
||||||
|
data-testid="template-direct-link"
|
||||||
|
className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors"
|
||||||
|
>
|
||||||
|
<Share2Icon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Direct link</Trans>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
@ -108,12 +123,6 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateDirectLinkDialog
|
|
||||||
template={row}
|
|
||||||
open={isTemplateDirectLinkDialogOpen}
|
|
||||||
onOpenChange={setTemplateDirectLinkDialogOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TemplateDeleteDialog
|
<TemplateDeleteDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export const TemplatesTable = ({
|
|||||||
const formatTemplateLink = (row: TemplatesTableRow) => {
|
const formatTemplateLink = (row: TemplatesTableRow) => {
|
||||||
const path = formatTemplatesPath(team.url);
|
const path = formatTemplatesPath(team.url);
|
||||||
|
|
||||||
return `${path}/${row.id}`;
|
return `${path}/${row.envelopeId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { OrganisationProvider } from '@documenso/lib/client-only/providers/organ
|
|||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { AppBanner } from '~/components/general/app-banner';
|
import { AppBanner } from '~/components/general/app-banner';
|
||||||
@ -42,7 +43,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ loaderData, params }: Route.ComponentProps) {
|
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
|
||||||
const { banner } = loaderData;
|
const { banner } = loaderData;
|
||||||
|
|
||||||
const { user, organisations } = useSession();
|
const { user, organisations } = useSession();
|
||||||
@ -71,6 +72,13 @@ export default function Layout({ loaderData, params }: Route.ComponentProps) {
|
|||||||
const orgNotFound = params.orgUrl && !currentOrganisation;
|
const orgNotFound = params.orgUrl && !currentOrganisation;
|
||||||
const teamNotFound = params.teamUrl && !currentTeam;
|
const teamNotFound = params.teamUrl && !currentTeam;
|
||||||
|
|
||||||
|
// Hide the header for editor routes.
|
||||||
|
const hideHeader = matches.some(
|
||||||
|
(match) =>
|
||||||
|
match?.id === 'routes/_authenticated+/t.$teamUrl+/documents.$id.edit' ||
|
||||||
|
match?.id === 'routes/_authenticated+/t.$teamUrl+/templates.$id.edit',
|
||||||
|
);
|
||||||
|
|
||||||
if (orgNotFound || teamNotFound) {
|
if (orgNotFound || teamNotFound) {
|
||||||
return (
|
return (
|
||||||
<GenericErrorLayout
|
<GenericErrorLayout
|
||||||
@ -110,9 +118,13 @@ export default function Layout({ loaderData, params }: Route.ComponentProps) {
|
|||||||
|
|
||||||
{banner && <AppBanner banner={banner} />}
|
{banner && <AppBanner banner={banner} />}
|
||||||
|
|
||||||
<Header />
|
{!hideHeader && <Header />}
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
|
<main
|
||||||
|
className={cn({
|
||||||
|
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</TeamProvider>
|
</TeamProvider>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { SigningStatus } from '@prisma/client';
|
import { EnvelopeType, SigningStatus } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@ -25,24 +25,31 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
|
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
|
import { AdminDocumentJobsTable } from '~/components/tables/admin-document-jobs-table';
|
||||||
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
|
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
|
||||||
|
|
||||||
import type { Route } from './+types/documents.$id';
|
import type { Route } from './+types/documents.$id';
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const id = Number(params.id);
|
const id = params.id;
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (!id || !id.startsWith('envelope_')) {
|
||||||
throw redirect('/admin/documents');
|
throw redirect('/admin/documents');
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = await getEntireDocument({ id });
|
const envelope = await unsafeGetEntireEnvelope({
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
});
|
||||||
|
|
||||||
return { document };
|
return { envelope };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminDocumentDetailsPage({ loaderData }: Route.ComponentProps) {
|
export default function AdminDocumentDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { document } = loaderData;
|
const { envelope } = loaderData;
|
||||||
|
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -51,8 +58,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
trpc.admin.document.reseal.useMutation({
|
trpc.admin.document.reseal.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Success`),
|
title: _(msg`Sealing job started`),
|
||||||
description: _(msg`Document resealed`),
|
description: _(msg`See the background jobs tab for the status`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@ -68,11 +75,11 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<h1 className="text-2xl font-semibold">{document.title}</h1>
|
<h1 className="text-2xl font-semibold">{envelope.title}</h1>
|
||||||
<DocumentStatus status={document.status} />
|
<DocumentStatus status={envelope.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{document.deletedAt && (
|
{envelope.deletedAt && (
|
||||||
<Badge size="large" variant="destructive">
|
<Badge size="large" variant="destructive">
|
||||||
<Trans>Deleted</Trans>
|
<Trans>Deleted</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -81,11 +88,11 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
|
|
||||||
<div className="text-muted-foreground mt-4 text-sm">
|
<div className="text-muted-foreground mt-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
|
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
|
<Trans>Last updated at</Trans>: {i18n.date(envelope.updatedAt, DateTime.DATETIME_MED)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -102,12 +109,12 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
loading={isResealDocumentLoading}
|
loading={isResealDocumentLoading}
|
||||||
disabled={document.recipients.some(
|
disabled={envelope.recipients.some(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
recipient.signingStatus !== SigningStatus.SIGNED &&
|
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||||
)}
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: envelope.id })}
|
||||||
>
|
>
|
||||||
<Trans>Reseal document</Trans>
|
<Trans>Reseal document</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
@ -123,7 +130,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link to={`/admin/users/${document.userId}`}>
|
<Link to={`/admin/users/${envelope.userId}`}>
|
||||||
<Trans>Go to owner</Trans>
|
<Trans>Go to owner</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -136,7 +143,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Accordion type="multiple" className="space-y-4">
|
<Accordion type="multiple" className="space-y-4">
|
||||||
{document.recipients.map((recipient) => (
|
{envelope.recipients.map((recipient) => (
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
value={recipient.id.toString()}
|
value={recipient.id.toString()}
|
||||||
@ -161,7 +168,13 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
{document && <AdminDocumentDeleteDialog document={document} />}
|
<div className="mt-4">
|
||||||
|
<AdminDocumentJobsTable envelopeId={envelope.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
{envelope && <AdminDocumentDeleteDialog envelopeId={envelope.id} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,7 +57,6 @@ export default function AdminDocumentsPage() {
|
|||||||
header: _(msg`Created`),
|
header: _(msg`Created`),
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||||
size: 160,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`Title`),
|
header: _(msg`Title`),
|
||||||
@ -65,20 +64,18 @@ export default function AdminDocumentsPage() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/admin/documents/${row.original.id}`}
|
to={`/admin/documents/${row.original.envelopeId}`}
|
||||||
className="block truncate font-medium hover:underline"
|
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
|
||||||
>
|
>
|
||||||
{row.original.title}
|
{row.original.title}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 240,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`Status`),
|
header: _(msg`Status`),
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||||
size: 140,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`Owner`),
|
header: _(msg`Owner`),
|
||||||
@ -115,13 +112,11 @@ export default function AdminDocumentsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 100,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Last updated',
|
header: 'Last updated',
|
||||||
accessorKey: 'updatedAt',
|
accessorKey: 'updatedAt',
|
||||||
cell: ({ row }) => i18n.date(row.original.updatedAt),
|
cell: ({ row }) => i18n.date(row.original.updatedAt),
|
||||||
size: 160,
|
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
import { useLingui } from '@lingui/react';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
|
||||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import {
|
||||||
|
DocumentReadOnlyFields,
|
||||||
|
mapFieldsWithRecipients,
|
||||||
|
} from '@documenso/ui/components/document/document-read-only-fields';
|
||||||
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
|
||||||
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||||
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
|
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
|
||||||
@ -27,90 +31,66 @@ import {
|
|||||||
DocumentStatus as DocumentStatusComponent,
|
DocumentStatus as DocumentStatusComponent,
|
||||||
FRIENDLY_STATUS_MAP,
|
FRIENDLY_STATUS_MAP,
|
||||||
} from '~/components/general/document/document-status';
|
} from '~/components/general/document/document-status';
|
||||||
|
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
|
||||||
|
import EnvelopeGenericPageRenderer from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||||
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import type { Route } from './+types/documents.$id._index';
|
import type { Route } from './+types/documents.$id._index';
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||||
const { user } = await getSession(request);
|
const { t } = useLingui();
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
const teamUrl = params.teamUrl;
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
if (!teamUrl) {
|
const {
|
||||||
throw new Response('Not Found', { status: 404 });
|
data: envelope,
|
||||||
|
isLoading: isLoadingEnvelope,
|
||||||
|
isError: isErrorEnvelope,
|
||||||
|
} = trpc.envelope.get.useQuery({
|
||||||
|
envelopeId: params.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoadingEnvelope) {
|
||||||
|
return (
|
||||||
|
<div className="text-foreground flex w-screen flex-col items-center justify-center gap-2 py-64">
|
||||||
|
<Spinner />
|
||||||
|
<Trans>Loading</Trans>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
if (isErrorEnvelope || !envelope) {
|
||||||
|
return (
|
||||||
const { id } = params;
|
<GenericErrorLayout
|
||||||
|
errorCode={404}
|
||||||
const documentId = Number(id);
|
errorCodeMap={{
|
||||||
|
404: {
|
||||||
|
heading: msg`Not found`,
|
||||||
|
subHeading: msg`404 Not found`,
|
||||||
|
message: msg`The document you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
primaryButton={
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/t/${team.url}/documents`}>
|
||||||
|
<Trans>Go back</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team.url);
|
const documentRootPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = await getDocumentWithDetailsById({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
// Todo: 401 or 404 page.
|
|
||||||
if (!document) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentVisibility = document?.visibility;
|
|
||||||
const currentTeamMemberRole = team.currentTeamRole;
|
|
||||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
|
||||||
|
|
||||||
let canAccessDocument = true;
|
|
||||||
|
|
||||||
if (!isRecipient && document?.userId !== user.id) {
|
|
||||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
|
||||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
|
||||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.otherwise(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document || !document.documentData || !canAccessDocument) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
logDocumentAccess({
|
|
||||||
request,
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return superLoaderJson({
|
|
||||||
document,
|
|
||||||
documentRootPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocumentPage() {
|
|
||||||
const loaderData = useSuperLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { user } = useSession();
|
|
||||||
|
|
||||||
const { document, documentRootPath } = loaderData;
|
|
||||||
|
|
||||||
const { recipients, documentData, documentMeta } = document;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
{document.status === DocumentStatus.PENDING && (
|
{envelope.status === DocumentStatus.PENDING && (
|
||||||
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
<DocumentRecipientLinkCopyDialog recipients={envelope.recipients} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
@ -122,35 +102,35 @@ export default function DocumentPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1
|
<h1
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
title={document.title}
|
title={envelope.title}
|
||||||
>
|
>
|
||||||
{document.title}
|
{envelope.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<DocumentStatusComponent
|
<DocumentStatusComponent
|
||||||
inheritColor
|
inheritColor
|
||||||
status={document.status}
|
status={envelope.status}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
{envelope.recipients.length > 0 && (
|
||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip
|
<StackAvatarsWithTooltip
|
||||||
recipients={recipients}
|
recipients={envelope.recipients}
|
||||||
documentStatus={document.status}
|
documentStatus={envelope.status}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Trans>{recipients.length} Recipient(s)</Trans>
|
<Trans>{envelope.recipients.length} Recipient(s)</Trans>
|
||||||
</span>
|
</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.deletedAt && (
|
{envelope.deletedAt && (
|
||||||
<Badge variant="destructive">
|
<Badge variant="destructive">
|
||||||
<Trans>Document deleted</Trans>
|
<Trans>Document deleted</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -165,33 +145,47 @@ export default function DocumentPage() {
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
|
{envelope.internalVersion === 2 ? (
|
||||||
|
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
|
||||||
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
|
|
||||||
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{envelope.status !== DocumentStatus.COMPLETED && (
|
||||||
|
<DocumentReadOnlyFields
|
||||||
|
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
|
||||||
|
documentMeta={envelope.documentMeta || undefined}
|
||||||
|
showRecipientTooltip={true}
|
||||||
|
showRecipientColors={true}
|
||||||
|
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PDFViewer
|
||||||
|
document={envelope}
|
||||||
|
key={envelope.envelopeItems[0].id}
|
||||||
|
documentData={envelope.envelopeItems[0].documentData}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
|
||||||
<DocumentReadOnlyFields
|
|
||||||
fields={document.fields}
|
|
||||||
documentMeta={documentMeta || undefined}
|
|
||||||
showRecipientTooltip={true}
|
|
||||||
showRecipientColors={true}
|
|
||||||
recipientIds={recipients.map((recipient) => recipient.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
|
{t(FRIENDLY_STATUS_MAP[envelope.status].labelExtended)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<DocumentPageViewDropdown document={document} />
|
<DocumentPageViewDropdown envelope={envelope} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||||
{match(document.status)
|
{match(envelope.status)
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<Trans>This document has been signed by all recipients</Trans>
|
<Trans>This document has been signed by all recipients</Trans>
|
||||||
))
|
))
|
||||||
@ -202,7 +196,7 @@ export default function DocumentPage() {
|
|||||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.PENDING, () => {
|
.with(DocumentStatus.PENDING, () => {
|
||||||
const pendingRecipients = recipients.filter(
|
const pendingRecipients = envelope.recipients.filter(
|
||||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -218,18 +212,21 @@ export default function DocumentPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 border-t px-4 pt-4">
|
<div className="mt-4 border-t px-4 pt-4">
|
||||||
<DocumentPageViewButton document={document} />
|
<DocumentPageViewButton envelope={envelope} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Document information section. */}
|
{/* Document information section. */}
|
||||||
<DocumentPageViewInformation document={document} userId={user.id} />
|
<DocumentPageViewInformation envelope={envelope} userId={user.id} />
|
||||||
|
|
||||||
{/* Recipients section. */}
|
{/* Recipients section. */}
|
||||||
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
|
<DocumentPageViewRecipients envelope={envelope} documentRootPath={documentRootPath} />
|
||||||
|
|
||||||
{/* Recent activity section. */}
|
{/* Recent activity section. */}
|
||||||
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
<DocumentPageViewRecentActivity
|
||||||
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
|
userId={user.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
import { ChevronLeftIcon } from 'lucide-react';
|
||||||
|
import { Link, Outlet, isRouteErrorResponse, redirect } from 'react-router';
|
||||||
|
|
||||||
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
|
|
||||||
|
import type { Route } from './+types/settings._layout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This file is very similar for templates as well. Any changes here should also be adjusted there as well.
|
||||||
|
*
|
||||||
|
* File: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._layout.tsx
|
||||||
|
*/
|
||||||
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
// If ID is a number, redirect to use envelope ID instead.
|
||||||
|
if (!Number.isNaN(documentId)) {
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({
|
||||||
|
userId: user.id,
|
||||||
|
teamUrl: params.teamUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await getEnvelopeById({
|
||||||
|
id: {
|
||||||
|
type: 'documentId',
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
}).catch((err) => {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
throw redirect(url.pathname.replace(`/documents/${id}`, `/documents/${envelope.id}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsLayout() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
|
||||||
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
|
|
||||||
|
const errorCodeMap = {
|
||||||
|
404: {
|
||||||
|
subHeading: msg`404 Document not found`,
|
||||||
|
heading: msg`Oops! Something went wrong.`,
|
||||||
|
message: msg`The document you are looking for could not be found.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericErrorLayout
|
||||||
|
errorCode={errorCode}
|
||||||
|
errorCodeMap={errorCodeMap}
|
||||||
|
secondaryButton={null}
|
||||||
|
primaryButton={
|
||||||
|
<Button asChild className="w-32">
|
||||||
|
<Link to={`/t/${params.teamUrl}/documents`}>
|
||||||
|
<ChevronLeftIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Go Back</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,157 +1,107 @@
|
|||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { useEffect } from 'react';
|
||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
|
||||||
import { Link, redirect } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
|
|
||||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Spinner } from '@documenso/ui/primitives/spinner';
|
||||||
|
|
||||||
|
import EnvelopeEditor from '~/components/general/envelope-editor/envelope-editor';
|
||||||
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import type { Route } from './+types/documents.$id.edit';
|
import type { Route } from './+types/documents.$id.edit';
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
||||||
const { user } = await getSession(request);
|
const navigate = useNavigate();
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const teamUrl = params.teamUrl;
|
const {
|
||||||
|
data: envelope,
|
||||||
if (!teamUrl) {
|
isLoading: isLoadingEnvelope,
|
||||||
throw new Response('Not Found', { status: 404 });
|
isError: isErrorEnvelope,
|
||||||
}
|
} = trpc.envelope.get.useQuery(
|
||||||
|
{
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
envelopeId: params.id,
|
||||||
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team.url);
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = await getDocumentWithDetailsById({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (document?.teamId && !team?.url) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentVisibility = document?.visibility;
|
|
||||||
const currentTeamMemberRole = team.currentTeamRole;
|
|
||||||
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
|
|
||||||
let canAccessDocument = true;
|
|
||||||
|
|
||||||
if (!isRecipient && document?.userId !== user.id) {
|
|
||||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
|
||||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
|
|
||||||
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
|
|
||||||
.otherwise(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (team && !canAccessDocument) {
|
|
||||||
throw redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDocumentCompleted(document.status)) {
|
|
||||||
throw redirect(`${documentRootPath}/${documentId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logDocumentAccess({
|
|
||||||
request,
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return superLoaderJson({
|
|
||||||
document: {
|
|
||||||
...document,
|
|
||||||
folder: null,
|
|
||||||
},
|
},
|
||||||
documentRootPath,
|
{
|
||||||
});
|
retry: false,
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default function DocumentEditPage() {
|
/**
|
||||||
const { document, documentRootPath } = useSuperLoaderData<typeof loader>();
|
* Need to handle redirecting to legacy editor on the client side to reduce server
|
||||||
|
* requests for the majority use case.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!envelope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { recipients } = document;
|
const pathPrefix =
|
||||||
|
envelope.type === EnvelopeType.DOCUMENT
|
||||||
|
? formatDocumentsPath(team.url)
|
||||||
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
|
if (envelope.teamId !== team.id) {
|
||||||
|
void navigate(pathPrefix, { replace: true });
|
||||||
|
} else if (envelope.internalVersion !== 2) {
|
||||||
|
void navigate(`${pathPrefix}/${envelope.id}/legacy_editor`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [envelope, team, navigate]);
|
||||||
|
|
||||||
|
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
|
||||||
|
return (
|
||||||
|
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
|
||||||
|
<Spinner />
|
||||||
|
<Trans>Redirecting</Trans>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingEnvelope) {
|
||||||
|
return (
|
||||||
|
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
|
||||||
|
<Spinner />
|
||||||
|
<Trans>Loading</Trans>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorEnvelope || !envelope) {
|
||||||
|
return (
|
||||||
|
<GenericErrorLayout
|
||||||
|
errorCode={404}
|
||||||
|
errorCodeMap={{
|
||||||
|
404: {
|
||||||
|
heading: msg`Not found`,
|
||||||
|
subHeading: msg`404 Not found`,
|
||||||
|
message: msg`The document you are looking for may have been removed, renamed or may have never
|
||||||
|
existed.`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
primaryButton={
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/t/${team.url}/documents`}>
|
||||||
|
<Trans>Go home</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||||
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<EnvelopeRenderProvider envelope={envelope}>
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<EnvelopeEditor />
|
||||||
<Trans>Documents</Trans>
|
</EnvelopeRenderProvider>
|
||||||
</Link>
|
</EnvelopeEditorProvider>
|
||||||
|
|
||||||
<div className="mt-4 flex w-full items-end justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1
|
|
||||||
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
|
||||||
title={document.title}
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<DocumentStatus
|
|
||||||
inheritColor
|
|
||||||
status={document.status}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<div className="text-muted-foreground flex items-center">
|
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
|
||||||
|
|
||||||
<StackAvatarsWithTooltip
|
|
||||||
recipients={recipients}
|
|
||||||
documentStatus={document.status}
|
|
||||||
position="bottom"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
|
||||||
</span>
|
|
||||||
</StackAvatarsWithTooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{document.useLegacyFieldInsertion && (
|
|
||||||
<div>
|
|
||||||
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentEditForm
|
|
||||||
className="mt-6"
|
|
||||||
initialDocument={document}
|
|
||||||
documentRootPath={documentRootPath}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,139 @@
|
|||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
||||||
|
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
|
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/documents.$id.edit';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { id, teamUrl } = params;
|
||||||
|
|
||||||
|
if (!id || !teamUrl) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
|
const document = await getDocumentWithDetailsById({
|
||||||
|
id: {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentVisibility = document.visibility;
|
||||||
|
const currentTeamMemberRole = team.currentTeamRole;
|
||||||
|
const isRecipient = document.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
|
let canAccessDocument = true;
|
||||||
|
|
||||||
|
if (!isRecipient && document.userId !== user.id) {
|
||||||
|
canAccessDocument = canAccessTeamDocument(currentTeamMemberRole, documentVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canAccessDocument) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDocumentCompleted(document.status)) {
|
||||||
|
throw redirect(`${documentRootPath}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logDocumentAccess({
|
||||||
|
request,
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
document: {
|
||||||
|
...document,
|
||||||
|
folder: null,
|
||||||
|
},
|
||||||
|
documentRootPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentEditPage() {
|
||||||
|
const { document, documentRootPath } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { recipients } = document;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
<Trans>Documents</Trans>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-4 flex w-full items-end justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1
|
||||||
|
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
|
title={document.title}
|
||||||
|
>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatus
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="text-muted-foreground flex items-center">
|
||||||
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
|
||||||
|
</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{document.useLegacyFieldInsertion && (
|
||||||
|
<div>
|
||||||
|
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentEditForm
|
||||||
|
className="mt-6"
|
||||||
|
initialDocument={document}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user