mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 18:04:55 +10:00
Merge branch 'main' into feat/document-file-conversion
Resolved conflicts:
- create-document-data.ts: merged initialData + originalData/originalMimeType
- files.helpers.ts: kept both imports
- envelope-drop-zone-wrapper.tsx: adopted buildDropzoneRejectionDescription
- create-envelope-items.ts: adopted UNSAFE_createEnvelopeItems
- create-envelope.ts: integrated convertToPdfIfNeeded into createEnvelopeRouteCaller
Extended putPdfFileServerSide to accept { initialData, originalData,
originalMimeType } options; updated seal-document.handler call site.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
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,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminSwapSubscriptionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sourceOrganisationId: string;
|
||||
sourceOrganisationName: string;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const AdminSwapSubscriptionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
sourceOrganisationId,
|
||||
sourceOrganisationName,
|
||||
userId,
|
||||
}: AdminSwapSubscriptionDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: orgsData } = trpc.admin.organisation.find.useQuery(
|
||||
{
|
||||
ownerUserId: userId,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const eligibleOrgs = useMemo(() => {
|
||||
if (!orgsData?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return orgsData.data.filter((org) => {
|
||||
if (org.id === sourceOrganisationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasActiveSubscription =
|
||||
org.subscription &&
|
||||
(org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE');
|
||||
|
||||
return !hasActiveSubscription;
|
||||
});
|
||||
}, [orgsData, sourceOrganisationId]);
|
||||
|
||||
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
|
||||
|
||||
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!selectedOrgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await swapSubscription({
|
||||
sourceOrganisationId,
|
||||
targetOrganisationId: selectedOrgId,
|
||||
});
|
||||
|
||||
await trpcUtils.admin.organisation.find.invalidate();
|
||||
await trpcUtils.admin.organisation.get.invalidate();
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Subscription moved successfully`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Failed to move subscription. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedOrgId('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isSubmitting && onOpenChange(value)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Move Subscription</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Move the subscription from "{sourceOrganisationName}" to another organisation owned by
|
||||
this user.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">
|
||||
<Trans>Target Organisation</Trans>
|
||||
</label>
|
||||
|
||||
<Select value={selectedOrgId} onValueChange={setSelectedOrgId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select an organisation`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligibleOrgs.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name} ({org.url})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{eligibleOrgs.length === 0 && orgsData && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>No eligible organisations found. The target must be on the free plan.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedOrg && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
This will move the subscription from "{sourceOrganisationName}" to "
|
||||
{selectedOrg.name}". The source organisation will be reset to the free plan.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!selectedOrgId}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
<Trans>Move Subscription</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -43,7 +44,7 @@ type ConfirmationDialogProps = {
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
});
|
||||
|
||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
@@ -115,10 +116,12 @@ export function AssistantConfirmationDialog({
|
||||
<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 className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
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>).
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
||||
@@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
||||
|
||||
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||
|
||||
export const ClaimCreateDialog = () => {
|
||||
type ClaimCreateDialogProps = {
|
||||
licenseFlags?: TLicenseClaim;
|
||||
};
|
||||
|
||||
export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -52,7 +57,7 @@ export const ClaimCreateDialog = () => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create Subscription Claim</Trans>
|
||||
@@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => {
|
||||
...generateDefaultSubscriptionClaim(),
|
||||
}}
|
||||
onFormSubmit={createClaim}
|
||||
licenseFlags={licenseFlags}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -21,9 +22,10 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
||||
export type ClaimUpdateDialogProps = {
|
||||
claim: TFindSubscriptionClaimsResponse['data'][number];
|
||||
trigger: React.ReactNode;
|
||||
licenseFlags?: TLicenseClaim;
|
||||
};
|
||||
|
||||
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
|
||||
export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -51,7 +53,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Update Subscription Claim</Trans>
|
||||
@@ -69,6 +71,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
|
||||
data,
|
||||
})
|
||||
}
|
||||
licenseFlags={licenseFlags}
|
||||
formSubmitTrigger={
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type DocumentDuplicateDialogProps = {
|
||||
id: string;
|
||||
token?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentDuplicateDialog = ({
|
||||
id,
|
||||
token,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentDuplicateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId: id,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpcReact.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: _(msg`Document Duplicated`),
|
||||
description: _(msg`Your document has been successfully duplicated.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${documentsPath}/${id}/edit`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateEnvelope({ envelopeId: id });
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be duplicated at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="original"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isDuplicating}
|
||||
loading={isDuplicating}
|
||||
onClick={onDuplicate}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,13 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
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';
|
||||
@@ -45,10 +46,10 @@ const FORM_ID = 'resend-email';
|
||||
export type DocumentResendDialogProps = {
|
||||
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
@@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
|
||||
+59
-33
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
@@ -12,61 +12,76 @@ import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DocumentDeleteDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
type EnvelopeDeleteDialogProps = {
|
||||
id: string;
|
||||
type: EnvelopeType;
|
||||
trigger?: React.ReactNode;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
title: string;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
export const DocumentDeleteDialog = ({
|
||||
export const EnvelopeDeleteDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
type,
|
||||
trigger,
|
||||
onDelete,
|
||||
status,
|
||||
documentTitle,
|
||||
title,
|
||||
canManageDocument,
|
||||
}: DocumentDeleteDialogProps) => {
|
||||
}: EnvelopeDeleteDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { refreshLimits } = useLimits();
|
||||
const { _ } = useLingui();
|
||||
const { t } = useLingui();
|
||||
|
||||
const deleteMessage = msg`delete`;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
|
||||
const isDocument = type === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
void refreshLimits();
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
description: _(msg`"${documentTitle}" has been successfully deleted`),
|
||||
title: canManageDocument
|
||||
? isDocument
|
||||
? t`Document deleted`
|
||||
: t`Template deleted`
|
||||
: isDocument
|
||||
? t`Document hidden`
|
||||
: t`Template hidden`,
|
||||
description: canManageDocument
|
||||
? t`"${title}" has been successfully deleted`
|
||||
: t`"${title}" has been successfully hidden`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await onDelete?.();
|
||||
|
||||
onOpenChange(false);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be deleted at this time. Please try again.`),
|
||||
title: t`Something went wrong`,
|
||||
description: isDocument
|
||||
? t`This document could not be deleted at this time. Please try again.`
|
||||
: t`This template could not be deleted at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
@@ -82,11 +97,13 @@ export const DocumentDeleteDialog = ({
|
||||
|
||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||
setIsDeleteEnabled(event.target.value === t(deleteMessage));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -96,11 +113,11 @@ export const DocumentDeleteDialog = ({
|
||||
<DialogDescription>
|
||||
{canManageDocument ? (
|
||||
<Trans>
|
||||
You are about to delete <strong>"{documentTitle}"</strong>
|
||||
You are about to delete <strong>"{title}"</strong>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You are about to hide <strong>"{documentTitle}"</strong>
|
||||
You are about to hide <strong>"{title}"</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</DialogDescription>
|
||||
@@ -111,10 +128,17 @@ export const DocumentDeleteDialog = ({
|
||||
{match(status)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
</Trans>
|
||||
{type === EnvelopeType.DOCUMENT ? (
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this template will be permanently deleted.
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => (
|
||||
@@ -176,23 +200,25 @@ export const DocumentDeleteDialog = ({
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
|
||||
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={() => void deleteDocument({ documentId: id })}
|
||||
onClick={() => void deleteEnvelope({ envelopeId: id })}
|
||||
disabled={!isDeleteEnabled && canManageDocument}
|
||||
variant="destructive"
|
||||
>
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -3,13 +3,7 @@ import { useEffect, 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,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
} from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -19,7 +13,10 @@ import * as z from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -66,10 +63,7 @@ export type EnvelopeDistributeDialogProps = {
|
||||
export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
meta: z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().email().optional(),
|
||||
),
|
||||
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
distributionMethod: z
|
||||
@@ -121,10 +115,15 @@ export const EnvelopeDistributeDialog = ({
|
||||
} = form;
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
});
|
||||
trpc.enterprise.organisation.email.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
@@ -140,14 +139,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
);
|
||||
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
() =>
|
||||
recipientsWithIndex.filter(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER &&
|
||||
!envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
() => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields),
|
||||
[recipientsWithIndex, envelope.fields],
|
||||
);
|
||||
|
||||
@@ -270,10 +262,10 @@ export const EnvelopeDistributeDialog = ({
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
|
||||
Email
|
||||
<Trans>Email</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
|
||||
None
|
||||
<Trans>None</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -10,6 +9,7 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -41,19 +41,20 @@ export const EnvelopeDuplicateDialog = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpc.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: t`Envelope Duplicated`,
|
||||
description: t`Your envelope has been successfully duplicated.`,
|
||||
title: isDocument ? t`Document Duplicated` : t`Template Duplicated`,
|
||||
description: isDocument
|
||||
? t`Your document has been successfully duplicated.`
|
||||
: t`Your template has been successfully duplicated.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
const path =
|
||||
envelopeType === EnvelopeType.DOCUMENT
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
const path = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${path}/${id}/edit`);
|
||||
setOpen(false);
|
||||
@@ -66,7 +67,9 @@ export const EnvelopeDuplicateDialog = ({
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be duplicated at this time. Please try again.`,
|
||||
description: isDocument
|
||||
? t`This document could not be duplicated at this time. Please try again.`
|
||||
: t`This template could not be duplicated at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
@@ -78,30 +81,25 @@ export const EnvelopeDuplicateDialog = ({
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
{envelopeType === EnvelopeType.DOCUMENT ? (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate Document</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
<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>
|
||||
)}
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
|
||||
<Trans>Duplicate</Trans>
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { AlertTriangleIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
|
||||
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZEditEnvelopeItemFormSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
});
|
||||
|
||||
type TEditEnvelopeItemFormSchema = z.infer<typeof ZEditEnvelopeItemFormSchema>;
|
||||
|
||||
/**
|
||||
* Note: This should only be visible if the envelope item is editable.
|
||||
*/
|
||||
export type EnvelopeItemEditDialogProps = {
|
||||
envelopeItem: { id: string; title: string };
|
||||
allowConfigureTitle: boolean;
|
||||
trigger: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopeItemEditDialog = ({
|
||||
envelopeItem,
|
||||
allowConfigureTitle,
|
||||
trigger,
|
||||
...props
|
||||
}: EnvelopeItemEditDialogProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { envelope, editorFields, setLocalEnvelope, isEmbedded } = useCurrentEnvelopeEditor();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [replacementFile, setReplacementFile] = useState<{ file: File; pageCount: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
|
||||
const form = useForm<TEditEnvelopeItemFormSchema>({
|
||||
resolver: zodResolver(ZEditEnvelopeItemFormSchema),
|
||||
defaultValues: {
|
||||
title: envelopeItem.title,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
|
||||
onSuccess: ({ data, fields }) => {
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === data.id
|
||||
? { ...item, documentDataId: data.documentDataId, title: data.title }
|
||||
: item,
|
||||
),
|
||||
});
|
||||
|
||||
if (fields) {
|
||||
setLocalEnvelope({ fields });
|
||||
editorFields.resetForm(fields);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const fieldsOnExcessPages =
|
||||
replacementFile !== null
|
||||
? envelope.fields.filter(
|
||||
(field) =>
|
||||
field.envelopeItemId === envelopeItem.id && field.page > replacementFile.pageCount,
|
||||
)
|
||||
: [];
|
||||
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
|
||||
if (!file || isDropping) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDropping(true);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fileData = new Uint8Array(arrayBuffer.slice(0));
|
||||
const { PDF } = await import('@libpdf/core');
|
||||
const pdfDoc = await PDF.load(fileData);
|
||||
|
||||
setReplacementFile({
|
||||
file,
|
||||
pageCount: pdfDoc.getPageCount(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: t`Failed to read file`,
|
||||
description: t`The file is not a valid PDF.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsDropping(false);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: { 'application/pdf': ['.pdf'] },
|
||||
maxFiles: 1,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
disabled: form.formState.isSubmitting,
|
||||
onDrop: (files) => void onFileDrop(files),
|
||||
onDropRejected: onFileDropRejected,
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TEditEnvelopeItemFormSchema) => {
|
||||
if (isDropping || !replacementFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { file, pageCount } = replacementFile;
|
||||
|
||||
if (isEmbedded) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fileData = new Uint8Array(arrayBuffer.slice(0));
|
||||
|
||||
const remainingFields = envelope.fields.filter(
|
||||
(field) => field.envelopeItemId !== envelopeItem.id || field.page <= pageCount,
|
||||
);
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === envelopeItem.id ? { ...item, title: data.title, data: fileData } : item,
|
||||
),
|
||||
fields: remainingFields,
|
||||
});
|
||||
|
||||
editorFields.resetForm(remainingFields);
|
||||
} else {
|
||||
const payload = {
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
title: data.title,
|
||||
} satisfies TReplaceEnvelopeItemPdfPayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
await replaceEnvelopeItemPdf(formData);
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Failed to update item`,
|
||||
description: t`Something went wrong while updating the envelope item.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset({ title: envelopeItem.title });
|
||||
setReplacementFile(null);
|
||||
setIsDropping(false);
|
||||
}
|
||||
}, [isOpen, form, envelopeItem.title]);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={isOpen}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Edit Item</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Update the title or replace the PDF file.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Document Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="envelope-item-edit-title-input"
|
||||
placeholder={t`Document Title`}
|
||||
disabled={!allowConfigureTitle}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<Trans>Replace PDF</Trans>
|
||||
</FormLabel>
|
||||
|
||||
{replacementFile ? (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div
|
||||
data-testid="envelope-item-edit-selected-file"
|
||||
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center space-x-2">
|
||||
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{replacementFile.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(replacementFile.file.size)}
|
||||
{isDropping ? ' · …' : ' · '}
|
||||
{!isDropping && replacementFile.pageCount !== null && (
|
||||
<Plural
|
||||
one="1 page"
|
||||
other="# pages"
|
||||
value={replacementFile.pageCount}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="envelope-item-edit-clear-file"
|
||||
onClick={() => {
|
||||
setReplacementFile(null);
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fieldsOnExcessPages.length > 0 && (
|
||||
<Alert variant="warning" padding="tight">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription data-testid="envelope-item-edit-field-warning">
|
||||
<Plural
|
||||
one="1 field will be deleted because the new PDF has fewer pages than the current one."
|
||||
other="# fields will be deleted because the new PDF has fewer pages than the current one."
|
||||
value={fieldsOnExcessPages.length}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="envelope-item-edit-dropzone"
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'mt-1.5 flex cursor-pointer items-center justify-center rounded-md border border-dashed border-border px-4 py-4 transition-colors',
|
||||
isDragActive
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'hover:border-muted-foreground/50 hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<UploadIcon className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Drop PDF here or click to select</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={isDropping || !replacementFile}
|
||||
data-testid="envelope-item-edit-update-button"
|
||||
>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,12 +4,13 @@ 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 { DocumentStatus, EnvelopeType, 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 type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -38,7 +39,7 @@ import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
export type EnvelopeRedistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
};
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeRenameDialogProps = {
|
||||
id: string;
|
||||
initialTitle: string;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onSuccess?: () => Promise<void>;
|
||||
envelopeType?: 'document' | 'template';
|
||||
};
|
||||
|
||||
export const EnvelopeRenameDialog = ({
|
||||
id,
|
||||
initialTitle,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
envelopeType = 'document',
|
||||
}: EnvelopeRenameDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(initialTitle);
|
||||
}
|
||||
}, [open, initialTitle]);
|
||||
|
||||
const isTemplate = envelopeType === 'template';
|
||||
|
||||
const { mutate: updateEnvelope, isPending } = trpcReact.envelope.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await onSuccess?.();
|
||||
|
||||
toast({
|
||||
title: isTemplate ? t`Template Renamed` : t`Document Renamed`,
|
||||
description: isTemplate
|
||||
? t`Your template has been successfully renamed.`
|
||||
: t`Your document has been successfully renamed.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
description: t`Something went wrong. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
|
||||
const onRename = () => {
|
||||
if (!trimmedTitle || trimmedTitle === initialTitle) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateEnvelope({
|
||||
envelopeId: id,
|
||||
data: {
|
||||
title: trimmedTitle,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isTemplate ? <Trans>Rename Template</Trans> : <Trans>Rename Document</Trans>}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
<Label htmlFor="title" className="sr-only">
|
||||
<Trans>Title</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
placeholder={t`Enter a new title`}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isPending}
|
||||
maxLength={DOCUMENT_TITLE_MAX_LENGTH}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending || !trimmedTitle || trimmedTitle === initialTitle}
|
||||
loading={isPending}
|
||||
onClick={() => void onRename()}
|
||||
>
|
||||
<Trans>Rename</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
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 { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type EnvelopeSaveAsTemplateDialogProps = {
|
||||
envelopeId: string;
|
||||
trigger?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnvelopeSaveAsTemplateDialog = ({
|
||||
envelopeId,
|
||||
trigger,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: EnvelopeSaveAsTemplateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
includeRecipients: true,
|
||||
includeFields: true,
|
||||
},
|
||||
});
|
||||
|
||||
const includeRecipients = form.watch('includeRecipients');
|
||||
|
||||
const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: t`Template Created`,
|
||||
description: t`Your document has been saved as a template.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${templatesPath}/${id}/edit`);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { includeRecipients, includeFields } = form.getValues();
|
||||
|
||||
try {
|
||||
await saveAsTemplate({
|
||||
envelopeId,
|
||||
includeRecipients,
|
||||
includeFields: includeRecipients && includeFields,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`This document could not be saved as a template at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(value);
|
||||
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Save as Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Create a template from this document.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="includeRecipients"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="envelopeIncludeRecipients"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked === true);
|
||||
|
||||
if (!checked) {
|
||||
form.setValue('includeFields', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="envelopeIncludeRecipients">
|
||||
<Trans>Include Recipients</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="includeFields"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="envelopeIncludeFields"
|
||||
checked={field.value}
|
||||
disabled={!includeRecipients}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="envelopeIncludeFields"
|
||||
className={!includeRecipients ? 'opacity-50' : ''}
|
||||
>
|
||||
<Trans>Include Fields</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isPending}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" loading={isPending} onClick={onSubmit}>
|
||||
<Trans>Save as Template</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
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,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopesBulkDeleteDialogProps = {
|
||||
envelopeIds: string[];
|
||||
envelopeType: EnvelopeType;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopesBulkDeleteDialog = ({
|
||||
envelopeIds,
|
||||
envelopeType,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkDeleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
// Invalidate the appropriate query based on envelope type.
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}
|
||||
|
||||
if (result.failedIds.length > 0) {
|
||||
toast({
|
||||
title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`,
|
||||
description: t`${plural(result.deletedCount, {
|
||||
one: '# item deleted.',
|
||||
other: '# items deleted.',
|
||||
})} ${plural(result.failedIds.length, {
|
||||
one: '# item could not be deleted.',
|
||||
other: '# items could not be deleted.',
|
||||
})}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: isDocument ? t`Documents deleted` : t`Templates deleted`,
|
||||
description: plural(result.deletedCount, {
|
||||
one: '# item has been deleted.',
|
||||
other: '# items have been deleted.',
|
||||
}),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while deleting the items.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="You are about to delete the selected document."
|
||||
other="You are about to delete # documents."
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="You are about to delete the selected template."
|
||||
other="You are about to delete # templates."
|
||||
/>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
{isDocument ? (
|
||||
<>
|
||||
<li>
|
||||
<Trans>Selected documents will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Pending documents will have their signing process cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All recipients will be notified</Trans>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
<Trans>Selected templates will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Direct links associated with templates will be removed</Trans>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void bulkDeleteEnvelopes({ envelopeIds });
|
||||
}}
|
||||
loading={isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
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';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopesBulkMoveDialogProps = {
|
||||
envelopeIds: string[];
|
||||
envelopeType: EnvelopeType;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentFolderId?: string;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZBulkMoveFormSchema = z.object({
|
||||
folderId: z.string().nullable(),
|
||||
});
|
||||
|
||||
type TBulkMoveFormSchema = z.infer<typeof ZBulkMoveFormSchema>;
|
||||
|
||||
export const EnvelopesBulkMoveDialog = ({
|
||||
envelopeIds,
|
||||
envelopeType,
|
||||
open,
|
||||
onOpenChange,
|
||||
currentFolderId,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkMoveDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const form = useForm<TBulkMoveFormSchema>({
|
||||
resolver: zodResolver(ZBulkMoveFormSchema),
|
||||
defaultValues: {
|
||||
folderId: currentFolderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId,
|
||||
type: envelopeType,
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation();
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchTerm('');
|
||||
|
||||
form.reset({
|
||||
folderId: currentFolderId,
|
||||
});
|
||||
}
|
||||
}, [open, currentFolderId]);
|
||||
|
||||
const onSubmit = async (data: TBulkMoveFormSchema) => {
|
||||
try {
|
||||
await bulkMoveEnvelopes({
|
||||
envelopeIds,
|
||||
folderId: data.folderId,
|
||||
envelopeType,
|
||||
});
|
||||
|
||||
// Invalidate the appropriate query based on envelope type.
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}
|
||||
|
||||
toast({
|
||||
description: t`Selected items have been moved.`,
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AppErrorCode.NOT_FOUND,
|
||||
() => t`The folder you are trying to move the items to does not exist.`,
|
||||
)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`)
|
||||
.with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`)
|
||||
.otherwise(() => t`An error occurred while moving the items.`);
|
||||
|
||||
toast({
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? (
|
||||
<Trans>Move Documents to Folder</Trans>
|
||||
) : (
|
||||
<Trans>Move Templates to Folder</Trans>
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="Select a folder to move the selected document to."
|
||||
other="Select a folder to move the # selected documents to."
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="Select a folder to move the selected template to."
|
||||
other="Select a folder to move the # selected templates to."
|
||||
/>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t`Search folders...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Folder</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{isFoldersLoading ? (
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === null ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(null)}
|
||||
disabled={currentFolderId === undefined}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home (No Folder)</Trans>
|
||||
</Button>
|
||||
|
||||
{filteredFolders?.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
variant={field.value === folder.id ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(folder.id)}
|
||||
disabled={currentFolderId === folder.id}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{folder.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{searchTerm && filteredFolders?.length === 0 && (
|
||||
<div className="px-2 py-2 text-center text-sm text-muted-foreground">
|
||||
<Trans>No folders found</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFoldersLoading || form.formState.isSubmitting}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -40,13 +40,21 @@ type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||
export type FolderCreateDialogProps = {
|
||||
type: FolderType;
|
||||
trigger?: React.ReactNode;
|
||||
parentFolderId?: string | null;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
|
||||
export const FolderCreateDialog = ({
|
||||
type,
|
||||
trigger,
|
||||
parentFolderId,
|
||||
...props
|
||||
}: FolderCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const parentId = parentFolderId ?? folderId;
|
||||
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||
@@ -62,7 +70,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
|
||||
try {
|
||||
await createFolder({
|
||||
name: data.name,
|
||||
parentId: folderId,
|
||||
parentId,
|
||||
type,
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export const OrganisationGroupDeleteDialog = ({
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
<Trans context="Removing group from organisation">
|
||||
You are about to remove the following group from{' '}
|
||||
<span className="font-semibold">{organisation.name}</span>.
|
||||
</Trans>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '@documenso/lib/constants/app'
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -94,7 +95,7 @@ type TabTypes = 'INDIVIDUAL' | 'BULK';
|
||||
|
||||
const ZImportOrganisationMemberSchema = z.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
email: zEmail(),
|
||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||
}),
|
||||
);
|
||||
@@ -329,12 +330,12 @@ export const OrganisationMemberInviteDialog = ({
|
||||
onValueChange={(value) => setInvitationType(value as TabTypes)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
|
||||
<TabsTrigger value="INDIVIDUAL" className="w-full hover:text-foreground">
|
||||
<MailIcon size={20} className="mr-2" />
|
||||
<Trans>Invite Members</Trans>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
|
||||
<TabsTrigger value="BULK" className="w-full hover:text-foreground">
|
||||
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -382,7 +383,7 @@ export const OrganisationMemberInviteDialog = ({
|
||||
)}
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -447,7 +448,7 @@ export const OrganisationMemberInviteDialog = ({
|
||||
<div className="mt-4 space-y-4">
|
||||
<Card gradient className="h-32">
|
||||
<CardContent
|
||||
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
|
||||
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createCallable } from 'react-call';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,10 +25,7 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldEmailFormSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.min(1, { message: msg`Email is required`.id }),
|
||||
email: zEmail().min(1, { message: msg`Email is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||
|
||||
@@ -127,7 +127,11 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
};
|
||||
|
||||
const mapTextToUrl = (text: string) => {
|
||||
return text.toLowerCase().replace(/\s+/g, '-');
|
||||
return text
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-');
|
||||
};
|
||||
|
||||
const dialogState = useMemo(() => {
|
||||
@@ -260,7 +264,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
{!form.formState.errors.teamUrl && (
|
||||
<span className="text-foreground/50 text-xs font-normal">
|
||||
<span className="text-xs font-normal text-foreground/50">
|
||||
{field.value ? (
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
|
||||
) : (
|
||||
@@ -288,7 +292,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
className="ml-2 text-sm text-muted-foreground"
|
||||
htmlFor="inherit-members"
|
||||
>
|
||||
<Trans>Allow all organisation members to access this team</Trans>
|
||||
|
||||
@@ -81,7 +81,7 @@ export const TeamGroupDeleteDialog = ({
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
<Trans context="Removing group from team">
|
||||
You are about to remove the following group from{' '}
|
||||
<span className="font-semibold">{team.name}</span>.
|
||||
</Trans>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { InfoIcon, UserPlusIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TeamMemberCreateDialogProps = {
|
||||
@@ -64,11 +68,20 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
||||
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const prevInviteDialogOpenRef = useRef(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const canInviteOrganisationMembers = canExecuteOrganisationAction(
|
||||
'MANAGE_ORGANISATION',
|
||||
organisation.currentOrganisationRole,
|
||||
);
|
||||
|
||||
const form = useForm<TAddTeamMembersFormSchema>({
|
||||
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
||||
@@ -96,7 +109,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
);
|
||||
}, [organisationMemberQuery, teamMemberQuery]);
|
||||
|
||||
const hasNoAvailableMembers =
|
||||
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
|
||||
|
||||
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
|
||||
if (members.length === 0) {
|
||||
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
|
||||
setInviteDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show error if on SELECT step - the disabled Next button already communicates this
|
||||
if (step === 'SELECT') {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`No members selected`,
|
||||
description: t`Please select at least one member to add to the team.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createTeamMembers({
|
||||
teamId: team.id,
|
||||
@@ -123,9 +158,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
setInviteDialogOpen(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
// Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members
|
||||
useEffect(() => {
|
||||
if (prevInviteDialogOpenRef.current && !inviteDialogOpen) {
|
||||
void utils.organisation.member.find.invalidate({
|
||||
organisationId: team.organisationId,
|
||||
});
|
||||
}
|
||||
prevInviteDialogOpenRef.current = inviteDialogOpen;
|
||||
}, [inviteDialogOpen, utils, team.organisationId]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
@@ -134,9 +180,11 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
// Since it would be annoying to redo the whole process.
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||
<Trans>Add members</Trans>
|
||||
</Button>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
||||
<Trans>Add members</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent hideClose={true} position="center">
|
||||
@@ -149,7 +197,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
|
||||
<TooltipContent className="z-[99999] max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
To be able to add members to a team, you must first add them to the
|
||||
organisation. For more information, please see the{' '}
|
||||
@@ -186,7 +234,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
.exhaustive()}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && form.getValues('members').length === 0) {
|
||||
e.preventDefault();
|
||||
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
|
||||
setInviteDialogOpen(true);
|
||||
}
|
||||
// Don't show toast - the disabled Next button already communicates this
|
||||
}
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={form.formState.isSubmitting}>
|
||||
{step === 'SELECT' && (
|
||||
<>
|
||||
@@ -194,46 +253,115 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
control={form.control}
|
||||
name="members"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormItem className="space-y-2">
|
||||
<FormLabel>
|
||||
<Trans>Members</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={avaliableOrganisationMembers.map((member) => ({
|
||||
label: member.name,
|
||||
value: member.id,
|
||||
}))}
|
||||
loading={organisationMemberQuery.isLoading}
|
||||
selectedValues={field.value.map(
|
||||
(member) => member.organisationMemberId,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
field.onChange(
|
||||
value.map((organisationMemberId) => ({
|
||||
organisationMemberId,
|
||||
teamRole:
|
||||
field.value.find(
|
||||
(member) =>
|
||||
member.organisationMemberId === organisationMemberId,
|
||||
)?.teamRole || TeamMemberRole.MEMBER,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className="bg-background w-full"
|
||||
emptySelectionPlaceholder={t`Select members`}
|
||||
/>
|
||||
{hasNoAvailableMembers ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/25 bg-muted/30 px-6 py-12 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<UserPlusIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-sm font-semibold">
|
||||
<Trans>No organisation members available</Trans>
|
||||
</h3>
|
||||
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
|
||||
{canInviteOrganisationMembers ? (
|
||||
<Trans>
|
||||
To add members to this team, you must first add them to the
|
||||
organisation.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To add members to this team, they must first be invited to the
|
||||
organisation. Only organisation admins and managers can invite
|
||||
new members — please contact one of them to invite members on
|
||||
your behalf.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
{canInviteOrganisationMembers && (
|
||||
<OrganisationMemberInviteDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
trigger={
|
||||
<Button type="button" variant="default">
|
||||
<UserPlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Invite organisation members</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MultiSelectCombobox
|
||||
options={avaliableOrganisationMembers.map((member) => ({
|
||||
label: member.name,
|
||||
value: member.id,
|
||||
}))}
|
||||
loading={organisationMemberQuery.isLoading}
|
||||
selectedValues={field.value.map(
|
||||
(member) => member.organisationMemberId,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
field.onChange(
|
||||
value.map((organisationMemberId) => ({
|
||||
organisationMemberId,
|
||||
teamRole:
|
||||
field.value.find(
|
||||
(member) =>
|
||||
member.organisationMemberId === organisationMemberId,
|
||||
)?.teamRole || TeamMemberRole.MEMBER,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className="w-full bg-background"
|
||||
emptySelectionPlaceholder={t`Select members`}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Select members to add to this team</Trans>
|
||||
</FormDescription>
|
||||
{!hasNoAvailableMembers && (
|
||||
<>
|
||||
<FormDescription>
|
||||
<Trans>Select members to add to this team</Trans>
|
||||
</FormDescription>
|
||||
|
||||
{canInviteOrganisationMembers && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
className="mt-2 flex items-center gap-2 space-y-0"
|
||||
>
|
||||
<div>
|
||||
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<AlertDescription className="mt-0 flex-1">
|
||||
<Trans>Can't find someone?</Trans>{' '}
|
||||
<OrganisationMemberInviteDialog
|
||||
open={inviteDialogOpen}
|
||||
onOpenChange={setInviteDialogOpen}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
<Trans>Invite them to the organisation first</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateDeleteDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const TemplateDeleteDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
}: TemplateDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await onDelete?.();
|
||||
|
||||
toast({
|
||||
title: _(msg`Template deleted`),
|
||||
description: _(msg`Your template has been successfully deleted.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This template could not be deleted at this time. Please try again.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Do you want to delete this template?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Please note that this action is irreversible. Once confirmed, your template will be
|
||||
permanently deleted.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isPending}
|
||||
onClick={async () => deleteTemplate({ templateId: id })}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||
import { RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||
import {
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
@@ -21,6 +21,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
@@ -52,8 +53,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
type TemplateDirectLinkDialogProps = {
|
||||
templateId: number;
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
trigger?: React.ReactNode;
|
||||
onCreateSuccess?: () => Promise<void> | void;
|
||||
onDeleteSuccess?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
||||
@@ -63,6 +66,8 @@ export const TemplateDirectLinkDialog = ({
|
||||
directLink,
|
||||
recipients,
|
||||
trigger,
|
||||
onCreateSuccess,
|
||||
onDeleteSuccess,
|
||||
}: TemplateDirectLinkDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { quota, remaining } = useLimits();
|
||||
@@ -97,6 +102,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
} = trpcReact.template.createTemplateDirectLink.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await revalidate();
|
||||
await onCreateSuccess?.();
|
||||
|
||||
setToken(data.token);
|
||||
setIsEnabled(data.enabled);
|
||||
@@ -142,6 +148,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
trpcReact.template.deleteTemplateDirectLink.useMutation({
|
||||
onSuccess: async () => {
|
||||
await revalidate();
|
||||
await onDeleteSuccess?.();
|
||||
|
||||
setOpen(false);
|
||||
setToken(null);
|
||||
@@ -234,7 +241,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold">{_(step.title)}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{_(step.description)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -320,13 +327,13 @@ export const TemplateDirectLinkDialog = ({
|
||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{row.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||
<p className="text-xs text-muted-foreground/70">{row.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
|
||||
</TableCell>
|
||||
|
||||
@@ -350,7 +357,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
<DialogFooter className="mx-auto">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{validDirectTemplateRecipients.length !== 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Or</Trans>
|
||||
</p>
|
||||
)}
|
||||
@@ -392,7 +399,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
<Trans>
|
||||
Disabling direct link signing will prevent anyone from accessing the
|
||||
link.
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateDuplicateDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const TemplateDuplicateDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TemplateDuplicateDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: duplicateTemplate, isPending } =
|
||||
trpcReact.template.duplicateTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Template duplicated`),
|
||||
description: _(msg`Your template has been duplicated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while duplicating template.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Do you want to duplicate this template?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="pt-2">
|
||||
<Trans>Your template will be duplicated.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isPending}
|
||||
onClick={async () =>
|
||||
duplicateTemplate({
|
||||
templateId: id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -79,7 +78,7 @@ export type TemplateUseDialogProps = {
|
||||
envelopeId: string;
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
documentDistributionMethod?: DocumentDistributionMethod;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
|
||||
+12
-10
@@ -1,4 +1,6 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentDistributionMethod } from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import type { Control } from 'react-hook-form';
|
||||
@@ -44,7 +46,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
control,
|
||||
isSubmitting,
|
||||
}: ConfigureDocumentAdvancedSettingsProps) => {
|
||||
const { t } = useLingui();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const form = useFormContext<TConfigureEmbedFormSchema>();
|
||||
const { features } = useConfigureDocument();
|
||||
@@ -58,11 +60,11 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-foreground mb-1 text-lg font-medium">
|
||||
<h3 className="mb-1 text-lg font-medium text-foreground">
|
||||
<Trans>Advanced Settings</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
<Trans>Configure additional options and preferences</Trans>
|
||||
</p>
|
||||
|
||||
@@ -93,12 +95,12 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||
label: t(option.label),
|
||||
label: _(option.label),
|
||||
value: option.value,
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
className="w-full bg-background"
|
||||
emptySelectionPlaceholder={t`Select signature types`}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -125,7 +127,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
<SelectContent>
|
||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{language.full}
|
||||
{_(language.full)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -202,7 +204,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Add a URL to redirect the user to once the document is signed
|
||||
</Trans>
|
||||
@@ -277,7 +279,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
<FormControl>
|
||||
<Input
|
||||
id="subject"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
disabled={isSubmitting || !isEmailDistribution}
|
||||
{...field}
|
||||
/>
|
||||
@@ -300,7 +302,7 @@ export const ConfigureDocumentAdvancedSettings = ({
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id="message"
|
||||
className="bg-background mt-2 h-32 resize-none"
|
||||
className="mt-2 h-32 resize-none bg-background"
|
||||
disabled={isSubmitting || !isEmailDistribution}
|
||||
{...field}
|
||||
/>
|
||||
|
||||
@@ -4,10 +4,11 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Cloud, FileText, Loader, X } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -82,10 +83,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
}
|
||||
};
|
||||
|
||||
const onDropRejected = () => {
|
||||
const onDropRejected = (fileRejections: FileRejection[]) => {
|
||||
toast({
|
||||
title: _(msg`Your document failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
description: _(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -144,7 +145,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
|
||||
'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background transition',
|
||||
{
|
||||
'border-primary/50 bg-primary/5': isDragActive,
|
||||
'hover:bg-muted/30':
|
||||
@@ -193,21 +194,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
|
||||
</FormControl>
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
|
||||
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{documentData.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatFileSize(documentData.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ZDocumentMetaLanguageSchema,
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||
|
||||
// Define the schema for configuration
|
||||
@@ -19,7 +20,7 @@ export const ZConfigureEmbedFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
formId: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||
signingOrder: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
|
||||
@@ -4,19 +4,20 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
||||
@@ -24,14 +25,15 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
|
||||
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
|
||||
|
||||
const MIN_HEIGHT_PX = 12;
|
||||
const MIN_WIDTH_PX = 36;
|
||||
@@ -42,7 +44,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||
export type ConfigureFieldsViewProps = {
|
||||
configData: TConfigureEmbedFormSchema;
|
||||
presignToken?: string | undefined;
|
||||
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>;
|
||||
defaultValues?: Partial<TConfigureFieldsFormSchema>;
|
||||
onBack?: (data: TConfigureFieldsFormSchema) => void;
|
||||
onSubmit: (data: TConfigureFieldsFormSchema) => void;
|
||||
@@ -86,26 +88,25 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
const normalizedDocumentData = useMemo(() => {
|
||||
if (envelopeItem) {
|
||||
return undefined;
|
||||
return getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: envelopeItem.envelopeId,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
documentDataId: envelopeItem.documentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (!configData.documentData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return base64.encode(configData.documentData.data);
|
||||
}, [configData.documentData]);
|
||||
|
||||
const normalizedEnvelopeItem = useMemo(() => {
|
||||
if (envelopeItem) {
|
||||
return envelopeItem;
|
||||
}
|
||||
|
||||
return { id: '', envelopeId: '' };
|
||||
}, [envelopeItem]);
|
||||
return configData.documentData.data;
|
||||
}, [configData.documentData, envelopeItem, presignToken]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
return configData.signers.map<Recipient>((signer, index) => ({
|
||||
return configData.signers.map<TRecipientLite>((signer, index) => ({
|
||||
id: signer.nativeId || index,
|
||||
name: signer.name || '',
|
||||
email: signer.email || '',
|
||||
@@ -115,7 +116,9 @@ export const ConfigureFieldsView = ({
|
||||
templateId: null,
|
||||
token: '',
|
||||
documentDeletedAt: null,
|
||||
expired: null,
|
||||
expired: null, // !: deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt: null,
|
||||
expirationNotifiedAt: null,
|
||||
signedAt: null,
|
||||
authOptions: null,
|
||||
rejectionReason: null,
|
||||
@@ -126,7 +129,7 @@ export const ConfigureFieldsView = ({
|
||||
}));
|
||||
}, [configData.signers]);
|
||||
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<TRecipientLite | null>(
|
||||
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
|
||||
);
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
@@ -153,9 +156,7 @@ export const ConfigureFieldsView = ({
|
||||
});
|
||||
|
||||
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
|
||||
const selectedRecipientStyles = useRecipientColors(
|
||||
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
|
||||
);
|
||||
const selectedRecipientStyles = getRecipientColorStyles(selectedRecipientIndex);
|
||||
|
||||
const form = useForm<TConfigureFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
@@ -177,8 +178,6 @@ export const ConfigureFieldsView = ({
|
||||
name: 'fields',
|
||||
});
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
@@ -203,13 +202,15 @@ export const ConfigureFieldsView = ({
|
||||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
const totalPages = getPdfPagesCount();
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
if (totalPages < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
@@ -222,7 +223,7 @@ export const ConfigureFieldsView = ({
|
||||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -546,17 +547,11 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
<Form {...form}>
|
||||
<div>
|
||||
<PDFViewerLazy
|
||||
presignToken={presignToken}
|
||||
overrideData={normalizedDocumentData}
|
||||
envelopeItem={normalizedEnvelopeItem}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
/>
|
||||
{normalizedDocumentData && (
|
||||
<PDFViewerLazy data={normalizedDocumentData} scrollParentRef="window" />
|
||||
)}
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
export const EmbedClientLoading = () => {
|
||||
return (
|
||||
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
||||
<div className="fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center bg-background">
|
||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
<span>Loading...</span>
|
||||
<span>
|
||||
<Trans>Loading...</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,37 +3,49 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import {
|
||||
type DocumentMeta,
|
||||
type EnvelopeItem,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
type Signature,
|
||||
} from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
|
||||
import {
|
||||
isFieldUnsignedAndRequired,
|
||||
isRequiredField,
|
||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||
@@ -47,7 +59,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
token: string;
|
||||
envelopeId: string;
|
||||
updatedAt: Date;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>[];
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | null;
|
||||
@@ -84,19 +96,18 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
||||
sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))),
|
||||
localFields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
@@ -199,6 +210,14 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { success: isEmailValid } = zEmail().safeParse(email);
|
||||
|
||||
if (!isEmailValid) {
|
||||
setEmailError(_(msg`A valid email is required`));
|
||||
setIsExpanded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
||||
|
||||
if (directTemplateExternalId) {
|
||||
@@ -284,12 +303,19 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
|
||||
void dynamicActivate(data.language).finally(() => {
|
||||
setHasFinishedInit(true);
|
||||
});
|
||||
} else {
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the two setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -335,9 +361,15 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: envelopeItems[0]?.envelopeId,
|
||||
envelopeItemId: envelopeItems[0]?.id,
|
||||
documentDataId: envelopeItems[0]?.documentDataId,
|
||||
version: 'current',
|
||||
token: recipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -421,11 +453,23 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="mt-2 bg-background"
|
||||
className={cn(
|
||||
'mt-2 bg-background',
|
||||
emailError && 'border-destructive ring-2 ring-destructive/20',
|
||||
)}
|
||||
disabled={isEmailLocked}
|
||||
value={email}
|
||||
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
|
||||
onChange={(e) => {
|
||||
if (!isEmailLocked) {
|
||||
setEmail(e.target.value.trim());
|
||||
setEmailError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{emailError && (
|
||||
<p className="mt-2 text-xs font-medium text-destructive">{emailError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSignatureField && (
|
||||
@@ -471,15 +515,15 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields
|
||||
@@ -492,7 +536,9 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -50,10 +50,8 @@ export const EmbedDocumentFields = ({
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: EmbedDocumentFieldsProps) => {
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
return (
|
||||
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
|
||||
@@ -4,13 +4,18 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
||||
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
@@ -22,15 +27,14 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||
@@ -44,7 +48,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
envelopeItems: (Pick<EnvelopeItem, 'id' | 'envelopeId'> & { documentData: { id: string } })[];
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
@@ -72,7 +76,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { fullName, email, signature, setFullName, setSignature } =
|
||||
const { fullName, email, signature, setFullName, setEmail, setSignature } =
|
||||
useRequiredDocumentSigningContext();
|
||||
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
@@ -87,6 +91,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [isEmailLocked, setIsEmailLocked] = useState(!!email);
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
useState(false);
|
||||
@@ -99,14 +104,14 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
fields.filter(
|
||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
||||
sortFieldsByPosition(
|
||||
fields.filter(
|
||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
||||
),
|
||||
),
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
@@ -115,7 +120,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
[fields],
|
||||
);
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
@@ -203,13 +208,19 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
try {
|
||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||
|
||||
if (!isCompleted && data.name) {
|
||||
if (!isCompleted && data.name && !fullName) {
|
||||
setFullName(data.name);
|
||||
}
|
||||
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
|
||||
if (!isCompleted && data.email && !email) {
|
||||
setEmail(data.email);
|
||||
setIsEmailLocked(!!data.lockEmail);
|
||||
}
|
||||
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
||||
|
||||
@@ -223,12 +234,19 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
|
||||
void dynamicActivate(data.language).finally(() => {
|
||||
setHasFinishedInit(true);
|
||||
});
|
||||
} else {
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the two setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -287,9 +305,15 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<PDFViewerLazy
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: envelopeItems[0]?.envelopeId,
|
||||
envelopeItemId: envelopeItems[0]?.id,
|
||||
documentDataId: envelopeItems[0]?.documentData.id,
|
||||
version: 'current',
|
||||
token: token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -441,7 +465,8 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
id="email"
|
||||
className="mt-2 bg-background"
|
||||
value={email}
|
||||
disabled
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isEmailLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -490,15 +515,15 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||
@@ -509,7 +534,9 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
|
||||
@@ -25,7 +28,7 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { envelope, recipient, envelopeData, setFullName, fullName } =
|
||||
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { isCompleted, isRejected, recipientSignature } = envelopeData;
|
||||
@@ -35,6 +38,9 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
const [isEmailLocked, setIsEmailLocked] = useState(
|
||||
envelope.type === EnvelopeType.DOCUMENT && !!email,
|
||||
);
|
||||
|
||||
const onDocumentCompleted = (data: {
|
||||
token: string;
|
||||
@@ -126,12 +132,26 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||
|
||||
if (!isCompleted && data.name) {
|
||||
setFullName(data.name);
|
||||
// For documents, only use the hash name if the recipient doesn't already have one.
|
||||
// For templates, always allow the hash name to be used.
|
||||
if (envelope.type === EnvelopeType.TEMPLATE || !fullName) {
|
||||
setFullName(data.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
|
||||
if (!isCompleted && data.email) {
|
||||
// For documents, only use the hash email if the recipient doesn't already have one.
|
||||
// For templates, always allow the hash email to be used.
|
||||
if (envelope.type === EnvelopeType.TEMPLATE || !email) {
|
||||
setEmail(data.email);
|
||||
setIsEmailLocked(!!data.lockEmail);
|
||||
}
|
||||
}
|
||||
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
@@ -144,12 +164,19 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
|
||||
void dynamicActivate(data.language).finally(() => {
|
||||
setHasFinishedInit(true);
|
||||
});
|
||||
} else {
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -213,6 +240,7 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
return (
|
||||
<EmbedSigningProvider
|
||||
isNameLocked={isNameLocked}
|
||||
isEmailLocked={isEmailLocked}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowDocumentRejection={allowDocumentRejection}
|
||||
onDocumentCompleted={onDocumentCompleted}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
export const EmbedRecipientExpired = () => {
|
||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.parent && !hasPostedMessage) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'recipient-expired',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasPostedMessage(true);
|
||||
}, [hasPostedMessage]);
|
||||
|
||||
if (!hasPostedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="embed--RecipientExpired relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-center text-2xl font-bold text-foreground">
|
||||
<Trans>Signing Window Expired</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-8 max-w-[50ch] text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Your signing window for this document has expired. Please contact the sender for a new
|
||||
invitation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
<Trans>Please check with the parent application for more information.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,12 +3,15 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentStatus, SigningStatus } from '@prisma/client';
|
||||
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
@@ -21,10 +24,11 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
||||
import { EmbedDocumentFields } from '../embed-document-fields';
|
||||
@@ -83,17 +87,17 @@ export const MultiSignDocumentSigningView = ({
|
||||
const { mutateAsync: completeDocumentWithToken } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const [pendingFields, completedFields] = [
|
||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
||||
[],
|
||||
sortFieldsByPosition(
|
||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
||||
[],
|
||||
),
|
||||
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
|
||||
[],
|
||||
];
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
||||
|
||||
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
||||
@@ -177,8 +181,8 @@ export const MultiSignDocumentSigningView = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen overflow-hidden bg-background">
|
||||
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="relative h-full w-full p-8">
|
||||
{match({ isLoading, document })
|
||||
.with({ isLoading: true }, () => (
|
||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
||||
@@ -226,9 +230,15 @@ export const MultiSignDocumentSigningView = ({
|
||||
})}
|
||||
>
|
||||
<PDFViewerLazy
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0]?.id,
|
||||
documentDataId: document.documentData.id,
|
||||
version: 'current',
|
||||
token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => {
|
||||
setHasDocumentLoaded(true);
|
||||
onDocumentReady?.();
|
||||
@@ -359,13 +369,11 @@ export const MultiSignDocumentSigningView = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasDocumentLoaded && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
|
||||
>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
{hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
|
||||
>
|
||||
<FieldToolTip
|
||||
key={pendingFields[0].id}
|
||||
field={pendingFields[0]}
|
||||
@@ -373,27 +381,27 @@ export const MultiSignDocumentSigningView = ({
|
||||
>
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
)}
|
||||
</ElementVisible>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
{hasDocumentLoaded && (
|
||||
<EmbedDocumentFields
|
||||
fields={pendingFields}
|
||||
metadata={document.documentMeta}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
)}
|
||||
{/* Fields */}
|
||||
{hasDocumentLoaded && (
|
||||
<EmbedDocumentFields
|
||||
fields={pendingFields}
|
||||
metadata={document.documentMeta}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Completed fields */}
|
||||
{document.status !== DocumentStatus.COMPLETED && (
|
||||
<DocumentReadOnlyFields
|
||||
documentMeta={document.documentMeta ?? undefined}
|
||||
fields={completedFields}
|
||||
/>
|
||||
)}
|
||||
{/* Completed fields */}
|
||||
{document.status !== DocumentStatus.COMPLETED && (
|
||||
<DocumentReadOnlyFields
|
||||
documentMeta={document.documentMeta ?? undefined}
|
||||
fields={completedFields}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function BrandingPreferencesForm({
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||
|
||||
setPreviewUrl(logoUrl);
|
||||
setPreviewUrl(logoUrl + '?v=' + Date.now());
|
||||
setHasLoadedPreview(true);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export function BrandingPreferencesForm({
|
||||
/>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-y-4">
|
||||
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9998]" />}
|
||||
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -185,7 +185,7 @@ export function BrandingPreferencesForm({
|
||||
</FormLabel>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
|
||||
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
@@ -193,12 +193,12 @@ export function BrandingPreferencesForm({
|
||||
className="h-full w-full object-contain p-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-muted/20 text-sm text-muted-foreground dark:bg-muted">
|
||||
<Trans>Please upload a logo</Trans>
|
||||
|
||||
{!hasLoadedPreview && (
|
||||
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-muted dark:bg-muted">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -243,7 +243,7 @@ export function BrandingPreferencesForm({
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-destructive text-xs"
|
||||
className="text-xs text-destructive"
|
||||
onClick={() => {
|
||||
setPreviewUrl('');
|
||||
onChange(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
|
||||
@@ -11,6 +11,14 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import {
|
||||
type TEnvelopeExpirationPeriod,
|
||||
ZEnvelopeExpirationPeriod,
|
||||
} from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
type TEnvelopeReminderSettings,
|
||||
ZEnvelopeReminderSettings,
|
||||
} from '@documenso/lib/constants/envelope-reminder';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
@@ -27,6 +35,8 @@ import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -70,6 +80,8 @@ export type TDocumentPreferencesFormSchema = {
|
||||
defaultRecipients: TDefaultRecipients | null;
|
||||
delegateDocumentOwnership: boolean | null;
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
||||
reminderSettings: TEnvelopeReminderSettings | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
@@ -87,6 +99,8 @@ type SettingsSubset = Pick<
|
||||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
| 'envelopeExpirationPeriod'
|
||||
| 'reminderSettings'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
@@ -102,7 +116,7 @@ export const DocumentPreferencesForm = ({
|
||||
canInherit,
|
||||
isAiFeaturesConfigured = false,
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { _ } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
const optionalTeam = useOptionalCurrentTeam();
|
||||
@@ -126,6 +140,8 @@ export const DocumentPreferencesForm = ({
|
||||
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
||||
delegateDocumentOwnership: z.boolean().nullable(),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
@@ -146,6 +162,8 @@ export const DocumentPreferencesForm = ({
|
||||
: null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: settings.reminderSettings ?? null,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
@@ -233,7 +251,7 @@ export const DocumentPreferencesForm = ({
|
||||
<SelectContent>
|
||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{language.full}
|
||||
{_(language.full)}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -333,7 +351,7 @@ export const DocumentPreferencesForm = ({
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
||||
label: t(option.label),
|
||||
label: _(option.label),
|
||||
value: option.value,
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
@@ -669,6 +687,64 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="envelopeExpirationPeriod"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Envelope Expiration</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ExpirationPeriodPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls how long recipients have to complete signing before the document
|
||||
expires. After expiration, recipients can no longer sign the document.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reminderSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Signing Reminders</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ReminderSettingsPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls when and how often reminder emails are sent to recipients who have not
|
||||
yet completed signing.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -184,7 +184,10 @@ export const EditorFieldCheckboxForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectTrigger
|
||||
data-testid="field-form-direction"
|
||||
className="w-full bg-background text-muted-foreground"
|
||||
>
|
||||
<SelectValue placeholder={t`Select direction`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -214,7 +217,10 @@ export const EditorFieldCheckboxForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectTrigger
|
||||
data-testid="field-form-validationRule"
|
||||
className="w-full bg-background text-muted-foreground"
|
||||
>
|
||||
<SelectValue placeholder={t`Select at least`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -260,7 +266,10 @@ export const EditorFieldCheckboxForm = ({
|
||||
void form.trigger();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
|
||||
<SelectTrigger
|
||||
data-testid="field-form-validationLength"
|
||||
className="mt-5 w-full bg-background text-muted-foreground"
|
||||
>
|
||||
<SelectValue placeholder={t`Pick a number`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -295,7 +304,7 @@ export const EditorFieldCheckboxForm = ({
|
||||
<Trans>Checkbox values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={() => addValue()}>
|
||||
<button type="button" data-testid="field-form-values-add" onClick={() => addValue()}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -310,7 +319,8 @@ export const EditorFieldCheckboxForm = ({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||
data-testid={`field-form-values-${index}-checked`}
|
||||
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
@@ -325,7 +335,11 @@ export const EditorFieldCheckboxForm = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input className="w-full" {...field} />
|
||||
<Input
|
||||
data-testid={`field-form-values-${index}-value`}
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -333,6 +347,7 @@ export const EditorFieldCheckboxForm = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`field-form-values-${index}-remove`}
|
||||
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)}
|
||||
>
|
||||
|
||||
@@ -96,7 +96,7 @@ export const EditorFieldDropdownForm = ({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
defaultValue: value.defaultValue,
|
||||
values: value.values || [{ value: 'Option 1' }],
|
||||
values: value.values || [{ value: t`Option 1` }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
|
||||
@@ -110,13 +110,13 @@ export const EditorFieldDropdownForm = ({
|
||||
const addValue = () => {
|
||||
const currentValues = form.getValues('values') || [];
|
||||
|
||||
let newValue = 'New option';
|
||||
let newValue = t`New option`;
|
||||
|
||||
// Iterate to create a unique value
|
||||
for (let i = 0; i < currentValues.length; i++) {
|
||||
newValue = `New option ${i + 1}`;
|
||||
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
|
||||
newValue = `New option ${i + 1}`;
|
||||
newValue = t`New option ${i + 1}`;
|
||||
if (currentValues.some((item) => item.value === t`New option ${i + 1}`)) {
|
||||
newValue = t`New option ${i + 1}`;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -176,7 +176,10 @@ export const EditorFieldDropdownForm = ({
|
||||
value={field.value ?? '-1'}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectTrigger
|
||||
data-testid="field-form-defaultValue"
|
||||
className="w-full bg-background text-muted-foreground"
|
||||
>
|
||||
<SelectValue placeholder={t`Default Value`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -215,7 +218,7 @@ export const EditorFieldDropdownForm = ({
|
||||
<Trans>Dropdown values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={addValue}>
|
||||
<button type="button" data-testid="field-form-values-add" onClick={addValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -229,7 +232,7 @@ export const EditorFieldDropdownForm = ({
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input data-testid={`field-form-values-${index}-value`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -238,6 +241,7 @@ export const EditorFieldDropdownForm = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`field-form-values-${index}-remove`}
|
||||
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)}
|
||||
>
|
||||
|
||||
@@ -50,6 +50,7 @@ export const EditorGenericFontSizeField = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="field-form-fontSize"
|
||||
type="number"
|
||||
min={8}
|
||||
max={96}
|
||||
@@ -88,7 +89,7 @@ export const EditorGenericTextAlignField = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger data-testid="field-form-textAlign">
|
||||
<SelectValue placeholder={t`Select text align`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -131,7 +132,7 @@ export const EditorGenericVerticalAlignField = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger data-testid="field-form-verticalAlign">
|
||||
<SelectValue placeholder={t`Select vertical align`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -174,6 +175,7 @@ export const EditorGenericLineHeightField = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="field-form-lineHeight"
|
||||
type="number"
|
||||
min={FIELD_MIN_LINE_HEIGHT}
|
||||
max={FIELD_MAX_LINE_HEIGHT}
|
||||
@@ -209,6 +211,7 @@ export const EditorGenericLetterSpacingField = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="field-form-letterSpacing"
|
||||
type="number"
|
||||
min={FIELD_MIN_LETTER_SPACING}
|
||||
max={FIELD_MAX_LETTER_SPACING}
|
||||
@@ -250,12 +253,13 @@ export const EditorGenericRequiredField = ({
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
data-testid="field-form-required"
|
||||
id="field-required"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-required">
|
||||
<label className="ml-2 text-sm text-muted-foreground" htmlFor="field-required">
|
||||
<Trans>Required Field</Trans>
|
||||
</label>
|
||||
</div>
|
||||
@@ -293,12 +297,13 @@ export const EditorGenericReadOnlyField = ({
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
data-testid="field-form-readOnly"
|
||||
id="field-read-only"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-read-only">
|
||||
<label className="ml-2 text-sm text-muted-foreground" htmlFor="field-read-only">
|
||||
<Trans>Read Only</Trans>
|
||||
</label>
|
||||
</div>
|
||||
@@ -329,7 +334,7 @@ export const EditorGenericLabelField = ({
|
||||
<Trans>Label</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field label`} {...field} />
|
||||
<Input data-testid="field-form-label" placeholder={t`Field label`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -167,7 +167,12 @@ export const EditorFieldNumberForm = ({
|
||||
<Trans>Placeholder</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" placeholder={t`Placeholder`} {...field} />
|
||||
<Input
|
||||
data-testid="field-form-placeholder"
|
||||
className="bg-background"
|
||||
placeholder={t`Placeholder`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -183,7 +188,12 @@ export const EditorFieldNumberForm = ({
|
||||
<Trans>Value</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" placeholder={t`Value`} {...field} />
|
||||
<Input
|
||||
data-testid="field-form-value"
|
||||
className="bg-background"
|
||||
placeholder={t`Value`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -203,7 +213,10 @@ export const EditorFieldNumberForm = ({
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectTrigger
|
||||
data-testid="field-form-numberFormat"
|
||||
className="w-full bg-background text-muted-foreground"
|
||||
>
|
||||
<SelectValue placeholder={t`Field format`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -257,8 +270,9 @@ export const EditorFieldNumberForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="field-form-minValue"
|
||||
className="bg-background"
|
||||
placeholder="E.g. 0"
|
||||
placeholder={t`E.g. 0`}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
@@ -281,8 +295,9 @@ export const EditorFieldNumberForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="field-form-maxValue"
|
||||
className="bg-background"
|
||||
placeholder="E.g. 100"
|
||||
placeholder={t`E.g. 100`}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -79,7 +79,7 @@ export const EditorFieldRadioForm = ({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
label: value.label || '',
|
||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||
values: value.values || [{ id: 1, checked: false, value: t`Default value` }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
direction: value.direction || 'vertical',
|
||||
@@ -140,7 +140,10 @@ export const EditorFieldRadioForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectTrigger
|
||||
data-testid="field-form-direction"
|
||||
className="w-full bg-background text-muted-foreground"
|
||||
>
|
||||
<SelectValue placeholder={t`Select direction`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -172,7 +175,7 @@ export const EditorFieldRadioForm = ({
|
||||
<Trans>Radio values</Trans>
|
||||
</p>
|
||||
|
||||
<button type="button" onClick={addValue}>
|
||||
<button type="button" data-testid="field-form-values-add" onClick={addValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -187,7 +190,8 @@ export const EditorFieldRadioForm = ({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||
data-testid={`field-form-values-${index}-checked`}
|
||||
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
// Uncheck all other values.
|
||||
@@ -216,7 +220,11 @@ export const EditorFieldRadioForm = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input className="w-full" {...field} />
|
||||
<Input
|
||||
data-testid={`field-form-values-${index}-value`}
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -224,6 +232,7 @@ export const EditorFieldRadioForm = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`field-form-values-${index}-remove`}
|
||||
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)}
|
||||
>
|
||||
|
||||
@@ -134,7 +134,7 @@ export const EditorFieldTextForm = ({
|
||||
<Trans>Label</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field label`} {...field} />
|
||||
<Input data-testid="field-form-label" placeholder={t`Field label`} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -150,7 +150,11 @@ export const EditorFieldTextForm = ({
|
||||
<Trans>Placeholder</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t`Field placeholder`} {...field} />
|
||||
<Input
|
||||
data-testid="field-form-placeholder"
|
||||
placeholder={t`Field placeholder`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -167,6 +171,7 @@ export const EditorFieldTextForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
data-testid="field-form-text"
|
||||
className="h-auto"
|
||||
placeholder={t`Add text to the field`}
|
||||
{...field}
|
||||
@@ -200,6 +205,7 @@ export const EditorFieldTextForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
data-testid="field-form-characterLimit"
|
||||
className="bg-background"
|
||||
placeholder={t`Character limit`}
|
||||
{...field}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
ZDocumentEmailSettingsSchema,
|
||||
} from '@documenso/lib/types/document-email';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
|
||||
const ZEmailPreferencesFormSchema = z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.string().email().nullable(),
|
||||
emailReplyTo: zEmail().nullable(),
|
||||
// emailReplyToName: z.string(),
|
||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
|
||||
});
|
||||
@@ -219,7 +220,8 @@ export const EmailPreferencesForm = ({
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the default email settings when new documents or templates are created
|
||||
Controls the default email settings when new documents or templates are created.
|
||||
Updating these settings will not affect existing documents or templates.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -21,7 +22,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZForgotPasswordFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
});
|
||||
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZSendConfirmationEmailFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
});
|
||||
|
||||
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { KeyRoundIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -16,7 +18,9 @@ import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -58,7 +62,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
||||
const LOGIN_REDIRECT_PATH = '/';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
password: ZCurrentPasswordSchema,
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
@@ -100,6 +104,10 @@ export const SignInForm = ({
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
const redirectPath = useMemo(() => {
|
||||
@@ -216,6 +224,7 @@ export const SignInForm = ({
|
||||
password,
|
||||
totpCode,
|
||||
backupCode,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
redirectPath,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -250,6 +259,10 @@ export const SignInForm = ({
|
||||
AuthenticationErrorCode.InvalidTwoFactorCode,
|
||||
() => msg`The two-factor authentication code provided is incorrect.`,
|
||||
)
|
||||
.with(
|
||||
AppErrorCode.INVALID_CAPTCHA,
|
||||
() => msg`We were unable to verify that you're human. Please try again.`,
|
||||
)
|
||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||
|
||||
toast({
|
||||
@@ -257,6 +270,9 @@ export const SignInForm = ({
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -377,6 +393,18 @@ export const SignInForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'invisible',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FaIdCardClip } from 'react-icons/fa6';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@@ -15,6 +17,8 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -39,7 +43,7 @@ export const ZSignUpFormSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
||||
})
|
||||
@@ -54,8 +58,8 @@ export const ZSignUpFormSchema = z
|
||||
},
|
||||
);
|
||||
|
||||
export const signupErrorMessages: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signups are disabled.`,
|
||||
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
|
||||
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
||||
};
|
||||
@@ -88,6 +92,11 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
@@ -110,6 +119,7 @@ export const SignUpForm = ({
|
||||
email,
|
||||
password,
|
||||
signature,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
});
|
||||
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
@@ -130,13 +140,17 @@ export const SignUpForm = ({
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
||||
const errorMessage =
|
||||
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,7 +210,7 @@ export const SignUpForm = ({
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-center gap-x-12', className)}>
|
||||
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
|
||||
<div className="relative hidden flex-1 overflow-hidden rounded-xl border border-border xl:flex">
|
||||
<div className="absolute -inset-8 -z-[2] backdrop-blur">
|
||||
<img
|
||||
src={communityCardsImage}
|
||||
@@ -205,17 +219,17 @@ export const SignUpForm = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
|
||||
<div className="absolute -inset-8 -z-[1] bg-background/50 backdrop-blur-[2px]" />
|
||||
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
|
||||
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
|
||||
<div className="rounded-2xl border bg-background px-4 py-1 text-sm font-medium">
|
||||
<Trans>User profiles are here!</Trans>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<UserProfileTimur
|
||||
rows={2}
|
||||
className="bg-background border-border rounded-2xl border shadow-md"
|
||||
className="rounded-2xl border border-border bg-background shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -223,13 +237,13 @@ export const SignUpForm = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
||||
<div className="relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
|
||||
<div className="h-20">
|
||||
<h1 className="text-xl font-semibold md:text-2xl">
|
||||
<Trans>Create a new account</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
|
||||
<p className="mt-2 text-xs text-muted-foreground md:text-sm">
|
||||
<Trans>
|
||||
Create your account and start using state-of-the-art document signing. Open and
|
||||
beautiful signing is within your grasp.
|
||||
@@ -244,13 +258,7 @@ export const SignUpForm = ({
|
||||
className="flex w-full flex-1 flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
hasSocialAuthEnabled && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -322,71 +330,76 @@ export const SignUpForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>Or</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
</>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="bg-transparent text-muted-foreground">
|
||||
<Trans>Or</Trans>
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with Google</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with Google</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with OIDC</Trans>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
<Trans>Sign Up with OIDC</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Already have an account?{' '}
|
||||
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||
@@ -402,11 +415,11 @@ export const SignUpForm = ({
|
||||
size="lg"
|
||||
className="mt-6 w-full"
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
<Trans>Create account</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p className="text-muted-foreground mt-6 text-xs">
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
By proceeding, you agree to our{' '}
|
||||
<Link
|
||||
|
||||
@@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { TLicenseClaim } from '@documenso/lib/types/license';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
@@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = {
|
||||
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
|
||||
formSubmitTrigger?: React.ReactNode;
|
||||
licenseFlags?: TLicenseClaim;
|
||||
};
|
||||
|
||||
export const SubscriptionClaimForm = ({
|
||||
subscriptionClaim,
|
||||
onFormSubmit,
|
||||
formSubmitTrigger,
|
||||
licenseFlags,
|
||||
}: SubscriptionClaimFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
|
||||
);
|
||||
|
||||
const form = useForm<SubscriptionClaimFormValues>({
|
||||
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
|
||||
defaultValues: {
|
||||
@@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
|
||||
</FormLabel>
|
||||
|
||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={`flags.${key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`flag-${key}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(
|
||||
({ key, label, isEnterprise }) => {
|
||||
const isRestrictedFeature =
|
||||
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
||||
htmlFor={`flag-${key}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={`flags.${key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`flag-${key}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
|
||||
/>
|
||||
|
||||
<label
|
||||
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
|
||||
htmlFor={`flag-${key}`}
|
||||
>
|
||||
{label}
|
||||
{isRestrictedFeature && ' ¹'}
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRestrictedEnterpriseFeatures && (
|
||||
<Alert variant="neutral" className="mt-4">
|
||||
<AlertDescription>
|
||||
<span>¹ </span>
|
||||
<Trans>Your current license does not include these features.</Trans>{' '}
|
||||
<Link
|
||||
to="https://docs.documenso.com/users/licenses/enterprise-edition"
|
||||
target="_blank"
|
||||
className="text-foreground underline hover:opacity-80"
|
||||
>
|
||||
<Trans>Learn more</Trans>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formSubmitTrigger}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type DetailsCardProps = {
|
||||
label: ReactNode;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const DetailsCard = ({ label, action, children }: DetailsCardProps) => {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<div className="flex min-h-9 items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{action ?? null}
|
||||
</div>
|
||||
<div className="mt-2 min-h-9">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type DetailsValueProps = {
|
||||
children: ReactNode;
|
||||
isMono?: boolean;
|
||||
isSelectable?: boolean;
|
||||
};
|
||||
|
||||
export const DetailsValue = ({
|
||||
children,
|
||||
isMono = true,
|
||||
isSelectable = false,
|
||||
}: DetailsValueProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-10 items-center break-all rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground',
|
||||
isMono && 'font-mono',
|
||||
isSelectable && 'select-all',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_VISIBILITY } from '@documenso/lib/constants/document-visibility';
|
||||
import {
|
||||
type TDocumentEmailSettings,
|
||||
ZDocumentEmailSettingsSchema,
|
||||
} from '@documenso/lib/types/document-email';
|
||||
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
|
||||
const EMAIL_SETTINGS_LABELS: Record<keyof TDocumentEmailSettings, MessageDescriptor> = {
|
||||
recipientSigningRequest: msg`Recipient signing request`,
|
||||
recipientRemoved: msg`Recipient removed`,
|
||||
recipientSigned: msg`Recipient signed`,
|
||||
documentPending: msg`Document pending`,
|
||||
documentCompleted: msg`Document completed`,
|
||||
documentDeleted: msg`Document deleted`,
|
||||
ownerDocumentCompleted: msg`Owner document completed`,
|
||||
ownerRecipientExpired: msg`Owner recipient expired`,
|
||||
ownerDocumentCreated: msg`Owner document created`,
|
||||
};
|
||||
|
||||
const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocumentEmailSettings)[];
|
||||
|
||||
type AdminGlobalSettingsSectionProps = {
|
||||
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
||||
isTeam?: boolean;
|
||||
};
|
||||
|
||||
export const AdminGlobalSettingsSection = ({
|
||||
settings,
|
||||
isTeam = false,
|
||||
}: AdminGlobalSettingsSectionProps) => {
|
||||
const { _ } = useLingui();
|
||||
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const brandingTextValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined || value.trim() === '') {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const booleanValue = (value: boolean | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
}
|
||||
|
||||
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
|
||||
};
|
||||
|
||||
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(
|
||||
settings.emailDocumentSettings,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
||||
<DetailsValue>
|
||||
{settings.documentVisibility != null
|
||||
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
|
||||
: notSetLabel}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document language</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Date format</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
||||
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
{isTeam && parsedEmailSettings.success && (
|
||||
<DetailsCard label={<Trans>Email document settings</Trans>}>
|
||||
<div className="mt-1 space-y-1 pb-2 pr-3 text-xs">
|
||||
{emailSettingsKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">{_(EMAIL_SETTINGS_LABELS[key])}</span>
|
||||
<span>
|
||||
{parsedEmailSettings.data[key] ? <Trans>On</Trans> : <Trans>Off</Trans>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailsCard>
|
||||
)}
|
||||
|
||||
<DetailsCard label={<Trans>AI features</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyRoundIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { TCachedLicense } from '@documenso/lib/types/license';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { CardMetric } from './metric-card';
|
||||
|
||||
type AdminLicenseCardProps = {
|
||||
licenseData: TCachedLicense | null;
|
||||
};
|
||||
|
||||
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
|
||||
|
||||
const { license } = licenseData || {};
|
||||
|
||||
if (!license) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute right-3 top-3 z-10">
|
||||
<AdminLicenseResyncButton />
|
||||
</div>
|
||||
<CardMetric icon={KeyRoundIcon} title={t`License`} className="h-fit max-h-fit">
|
||||
<div className="mt-1 flex items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50">
|
||||
<KeyRoundIcon className="h-5 w-5 text-muted-foreground/50" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{licenseData?.requestedLicenseKey ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
<Trans>Invalid License Key</Trans>
|
||||
</p>
|
||||
{/* Don't need to hide invalid license keys. */}
|
||||
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<Trans>No License Configured</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="https://docs.documenso.com/users/licenses/enterprise-edition"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center text-xs text-muted-foreground hover:text-muted-foreground/80"
|
||||
>
|
||||
<Trans>Learn more</Trans> <ArrowRightIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardMetric>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pb-6 pt-4 shadow shadow-transparent duration-200 hover:shadow-border/80">
|
||||
<div className="absolute right-3 top-3">
|
||||
<AdminLicenseResyncButton />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="h-4 w-4">
|
||||
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
|
||||
<Trans>Documenso License</Trans>
|
||||
</h3>
|
||||
|
||||
{match(license.status)
|
||||
.with('ACTIVE', () => (
|
||||
<Badge variant="default" size="small">
|
||||
<CheckCircle2Icon className="mr-1 h-3 w-3" />
|
||||
<Trans context="Subscription status">Active</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<Badge variant="warning" size="small">
|
||||
<XCircleIcon className="mr-1 h-3 w-3" />
|
||||
<Trans context="Subscription status">Past Due</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with('EXPIRED', () => (
|
||||
<Badge variant="destructive" size="small">
|
||||
<XCircleIcon className="mr-1 h-3 w-3" />
|
||||
<Trans context="Subscription status">Expired</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>License</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{license.name}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>Expires</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{i18n.date(license.periodEnd, DateTime.DATE_MED)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>License Key</Trans>
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<p className="min-w-0 break-all text-xs text-muted-foreground">
|
||||
{isLicenseKeyVisible ? license.licenseKey : '•'.repeat(license.licenseKey.length)}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
aria-label={isLicenseKeyVisible ? t`Hide license key` : t`Show license key`}
|
||||
onClick={() => setIsLicenseKeyVisible((prevState) => !prevState)}
|
||||
>
|
||||
{isLicenseKeyVisible ? (
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<Trans>Features</Trans>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{enabledFlags.length > 0 ? (
|
||||
enabledFlags
|
||||
.map(
|
||||
([flag]) =>
|
||||
SUBSCRIPTION_CLAIM_FEATURE_FLAGS[
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
flag as keyof typeof SUBSCRIPTION_CLAIM_FEATURE_FLAGS
|
||||
]?.label || flag,
|
||||
)
|
||||
.join(', ')
|
||||
) : (
|
||||
<Trans>No features enabled</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminLicenseResyncButton = () => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { mutate: resyncLicense, isPending: isResyncingLicense } =
|
||||
trpc.admin.license.resync.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: t`License synced`,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Failed to sync license`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isResyncingLicense}
|
||||
onClick={() => resyncLicense()}
|
||||
>
|
||||
{isResyncingLicense ? (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Sync license from server</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { TCachedLicense } from '@documenso/lib/types/license';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type AdminLicenseStatusBannerProps = {
|
||||
license: TCachedLicense | null;
|
||||
};
|
||||
|
||||
export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => {
|
||||
const licenseStatus = license?.derivedStatus;
|
||||
|
||||
if (!license || licenseStatus === 'ACTIVE' || licenseStatus === 'NOT_FOUND') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('mb-8 rounded-lg bg-yellow-200 text-yellow-900 dark:bg-yellow-400', {
|
||||
'bg-destructive text-destructive-foreground':
|
||||
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-x-4 px-4 py-3 text-sm font-medium">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangleIcon className="mr-2.5 h-5 w-5" />
|
||||
|
||||
{match(licenseStatus)
|
||||
.with('PAST_DUE', () => (
|
||||
<Trans>
|
||||
License Payment Overdue - Please update your payment to avoid service disruptions.
|
||||
</Trans>
|
||||
))
|
||||
.with('EXPIRED', () => (
|
||||
<Trans>
|
||||
License Expired - Please renew your license to continue using enterprise features.
|
||||
</Trans>
|
||||
))
|
||||
.with('UNAUTHORIZED', () =>
|
||||
license ? (
|
||||
<Trans>
|
||||
Invalid License Type - Your Documenso instance is using features that are not part
|
||||
of your license.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Missing License - Your Documenso instance is using features that require a
|
||||
license.
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn({
|
||||
'border-yellow-900/30 text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
|
||||
licenseStatus === 'PAST_DUE',
|
||||
'border-destructive-foreground/30 text-destructive-foreground hover:bg-destructive/80':
|
||||
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
|
||||
})}
|
||||
asChild
|
||||
>
|
||||
<Link to="https://docs.documenso.com/users/licenses/enterprise-edition" target="_blank">
|
||||
<KeyRoundIcon className="mr-1.5 h-4 w-4" />
|
||||
<Trans>See Documentation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -65,14 +66,16 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const debouncedSearch = useDebouncedValue(search, 200);
|
||||
const hasValidSearch = debouncedSearch.trim().length > 0;
|
||||
|
||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||
const { data: searchDocumentsData, isFetching: isFetchingDocuments } =
|
||||
trpcReact.document.search.useQuery(
|
||||
{
|
||||
query: debouncedSearch,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
enabled: open === true && hasValidSearch,
|
||||
placeholderData: keepPreviousData,
|
||||
// Do not batch this due to relatively long request time compared to
|
||||
// other queries which are generally batched with this.
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
@@ -80,6 +83,19 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const { data: searchTemplatesData, isFetching: isFetchingTemplates } =
|
||||
trpcReact.template.search.useQuery(
|
||||
{
|
||||
query: debouncedSearch,
|
||||
},
|
||||
{
|
||||
enabled: open === true && hasValidSearch,
|
||||
placeholderData: keepPreviousData,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const teamUrl = useMemo(() => {
|
||||
let teamUrl = currentTeam?.url || null;
|
||||
|
||||
@@ -134,17 +150,23 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
];
|
||||
}, [currentTeam, organisations]);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchDocumentsData) {
|
||||
return [];
|
||||
}
|
||||
const documentSearchResults =
|
||||
hasValidSearch && searchDocumentsData
|
||||
? searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: document.path,
|
||||
value: document.value,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: document.path,
|
||||
value: document.value,
|
||||
}));
|
||||
}, [searchDocumentsData]);
|
||||
const templateSearchResults =
|
||||
hasValidSearch && searchTemplatesData
|
||||
? searchTemplatesData.map((template) => ({
|
||||
label: template.title,
|
||||
path: template.path,
|
||||
value: template.value,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
@@ -222,19 +244,9 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
{isSearchingDocuments ? (
|
||||
<CommandEmpty>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="animate-spin">
|
||||
<Loader />
|
||||
</span>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
)}
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
{!currentPage && (
|
||||
<>
|
||||
@@ -256,16 +268,34 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
||||
Change language
|
||||
{_(msg`Change language`)}
|
||||
</CommandItem>
|
||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
|
||||
Change theme
|
||||
{_(msg`Change theme`)}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
{(isFetchingDocuments || documentSearchResults.length > 0) && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
||||
<Commands push={push} pages={searchResults} />
|
||||
{isFetchingDocuments ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Commands push={push} pages={documentSearchResults} />
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{(isFetchingTemplates || templateSearchResults.length > 0) && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your templates`)}>
|
||||
{isFetchingTemplates ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Commands push={push} pages={templateSearchResults} />
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
@@ -367,15 +397,14 @@ const LanguageCommands = () => {
|
||||
return Object.values(SUPPORTED_LANGUAGES).map((language) => (
|
||||
<CommandItem
|
||||
disabled={isLoading}
|
||||
key={language.full}
|
||||
key={language.short}
|
||||
onSelect={async () => setLanguage(language.short)}
|
||||
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn('mr-2 h-4 w-4', i18n.locale === language.short ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
|
||||
{language.full}
|
||||
{_(language.full)}
|
||||
</CommandItem>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -97,13 +97,13 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
{menuNavigationLinks.map(({ href, text }) => (
|
||||
<Link
|
||||
key={href}
|
||||
className="text-foreground hover:text-foreground/80 flex items-center gap-2 text-2xl font-semibold"
|
||||
className="flex items-center gap-2 text-2xl font-semibold text-foreground hover:text-foreground/80"
|
||||
to={href}
|
||||
onClick={() => handleMenuItemClick()}
|
||||
>
|
||||
{text}
|
||||
{href === '/inbox' && unreadCountData && unreadCountData.count > 0 && (
|
||||
<span className="bg-primary text-primary-foreground flex h-6 min-w-[1.5rem] items-center justify-center rounded-full px-1.5 text-xs font-semibold">
|
||||
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full bg-primary px-1.5 text-xs font-semibold text-primary-foreground">
|
||||
{unreadCountData.count > 99 ? '99+' : unreadCountData.count}
|
||||
</span>
|
||||
)}
|
||||
@@ -111,7 +111,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
))}
|
||||
|
||||
<button
|
||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||
className="text-2xl font-semibold text-foreground hover:text-foreground/80"
|
||||
onClick={async () => authClient.signOut()}
|
||||
>
|
||||
<Trans>Sign Out</Trans>
|
||||
@@ -123,8 +123,10 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} Documenso, Inc.
|
||||
<br />
|
||||
<Trans>All rights reserved.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -14,7 +14,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: Recipient;
|
||||
recipient: TRecipientLite;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
|
||||
@@ -118,7 +118,9 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
||||
|
||||
{price.product.features && price.product.features.length > 0 && (
|
||||
<div className="mt-4 text-muted-foreground">
|
||||
<div className="text-sm font-medium">Includes:</div>
|
||||
<div className="text-sm font-medium">
|
||||
<Trans>Includes:</Trans>
|
||||
</div>
|
||||
|
||||
<ul className="mt-1 divide-y text-sm">
|
||||
{price.product.features.map((feature, index) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { z } from 'zod';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { signupErrorMessages } from '~/components/forms/signup';
|
||||
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
||||
|
||||
export type ClaimAccountProps = {
|
||||
defaultName: string;
|
||||
@@ -37,7 +38,7 @@ export const ZClaimAccountFormSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
email: z.string().email().min(1),
|
||||
email: zEmail().min(1),
|
||||
password: ZPasswordSchema,
|
||||
})
|
||||
.refine(
|
||||
@@ -90,7 +91,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
||||
const errorMessage =
|
||||
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
|
||||
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DefaultRecipientsMultiSelectComboboxProps = {
|
||||
listValues: TDefaultRecipient[];
|
||||
@@ -20,6 +23,8 @@ export const DefaultRecipientsMultiSelectCombobox = ({
|
||||
organisationId,
|
||||
}: DefaultRecipientsMultiSelectComboboxProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { t } = useLinguiMacro();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: organisationData, isLoading: isLoadingOrganisation } =
|
||||
trpc.organisation.member.find.useQuery(
|
||||
@@ -60,31 +65,56 @@ export const DefaultRecipientsMultiSelectCombobox = ({
|
||||
}));
|
||||
|
||||
const onSelectionChange = (selected: Option[]) => {
|
||||
const updatedRecipients = selected.map((option) => {
|
||||
const existingRecipient = listValues.find((r) => r.email === option.value);
|
||||
const member = members?.find((m) => m.email === option.value);
|
||||
const invalidEmails = selected.filter(
|
||||
(option) => !isRecipientEmailValidForSending({ email: option.value }),
|
||||
);
|
||||
|
||||
return {
|
||||
email: option.value,
|
||||
name: member?.name || option.value,
|
||||
role: existingRecipient?.role ?? RecipientRole.CC,
|
||||
};
|
||||
});
|
||||
if (invalidEmails.length > 0) {
|
||||
toast({
|
||||
title: t`Invalid email`,
|
||||
description: t`"${invalidEmails[0].value}" is not a valid email address.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRecipients = selected
|
||||
.filter((option) => !invalidEmails.includes(option))
|
||||
.map((option) => {
|
||||
const existingRecipient = listValues.find((r) => r.email === option.value);
|
||||
const member = members?.find((m) => m.email === option.value);
|
||||
|
||||
return {
|
||||
email: option.value,
|
||||
name: member?.name || option.value,
|
||||
role: existingRecipient?.role ?? RecipientRole.CC,
|
||||
};
|
||||
});
|
||||
|
||||
onChange(updatedRecipients);
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
commandProps={{ label: _(msg`Select recipients`) }}
|
||||
commandProps={{ label: _(msg`Select or add recipients`) }}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onSelectionChange}
|
||||
placeholder={_(msg`Select recipients`)}
|
||||
placeholder={_(msg`Select or enter email address`)}
|
||||
hideClearAllButton
|
||||
hidePlaceholderWhenSelected
|
||||
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
|
||||
emptyIndicator={<p className="text-center text-sm">No members found</p>}
|
||||
creatable
|
||||
loadingIndicator={
|
||||
isLoading ? (
|
||||
<p className="text-center text-sm">
|
||||
<Trans>Loading...</Trans>
|
||||
</p>
|
||||
) : undefined
|
||||
}
|
||||
emptyIndicator={
|
||||
<p className="text-center text-sm">
|
||||
<Trans>Type an email address to add a recipient</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import {
|
||||
DocumentReadOnlyFields,
|
||||
mapFieldsWithRecipients,
|
||||
@@ -33,7 +34,7 @@ import { useStep } from '@documenso/ui/primitives/stepper';
|
||||
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
|
||||
const ZDirectTemplateConfigureFormSchema = z.object({
|
||||
email: z.string().email('Email is invalid'),
|
||||
email: zEmail('Email is invalid'),
|
||||
});
|
||||
|
||||
export type TDirectTemplateConfigureFormSchema = z.infer<typeof ZDirectTemplateConfigureFormSchema>;
|
||||
|
||||
@@ -9,16 +9,17 @@ import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import {
|
||||
DirectTemplateConfigureForm,
|
||||
@@ -153,9 +154,15 @@ export const DirectTemplatePageView = ({
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
key={template.id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={directTemplateRecipient.token}
|
||||
version="signed"
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: template.envelopeId,
|
||||
envelopeItemId: template.envelopeItems[0]?.id,
|
||||
documentDataId: template.templateDocumentDataId,
|
||||
version: 'current',
|
||||
token: directTemplateRecipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -82,8 +82,6 @@ export const DirectTemplateSigningForm = ({
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
|
||||
|
||||
const fieldsRequiringValidation = useMemo(() => {
|
||||
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
|
||||
}, [localFields]);
|
||||
@@ -250,9 +248,7 @@ export const DirectTemplateSigningForm = ({
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
@@ -27,7 +28,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
|
||||
|
||||
export type DocumentSigningAuth2FAProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
@@ -44,7 +44,6 @@ type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
|
||||
|
||||
export const DocumentSigningAuth2FA = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -101,14 +100,39 @@ export const DocumentSigningAuth2FA = ({
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
|
||||
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
|
||||
) : (
|
||||
// Todo: Translate
|
||||
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
|
||||
)}
|
||||
{match({ role: recipient.role, actionTarget })
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to sign this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to sign this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to approve this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to approve this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to view this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to view this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to view this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup 2FA to assist with this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup 2FA to assist with this document.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<Trans>
|
||||
By enabling 2FA, you will be required to enter a code from your authenticator app
|
||||
@@ -138,7 +162,9 @@ export const DocumentSigningAuth2FA = ({
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>2FA token</FormLabel>
|
||||
<FormLabel required>
|
||||
<Trans>2FA token</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
|
||||
+105
-28
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
@@ -13,13 +14,11 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
|
||||
|
||||
export type DocumentSigningAuthAccountProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentSigningAuthAccount = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthAccountProps) => {
|
||||
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||
@@ -55,32 +54,110 @@ export const DocumentSigningAuthAccount = ({
|
||||
<fieldset disabled={isSigningOut} className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||
<span>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in as <strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{match({ role: recipient.role, actionTarget })
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To sign this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To sign this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To sign this document, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To sign this document, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To approve this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To approve this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To approve this document, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To approve this document, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To view this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To view this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To view this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To view this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To view this document, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To view this document, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To assist with this field, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To assist with this field, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () =>
|
||||
isDirectTemplate ? (
|
||||
<Trans>To assist with this document, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To assist with this document, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.exhaustive()}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
+100
-14
@@ -8,6 +8,7 @@ import { RecipientRole } from '@prisma/client';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
@@ -38,7 +39,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
|
||||
|
||||
export type DocumentSigningAuthPasskeyProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
@@ -52,7 +52,6 @@ type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
|
||||
|
||||
export const DocumentSigningAuthPasskey = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -128,9 +127,62 @@ export const DocumentSigningAuthPasskey = ({
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{/* Todo: Translate */}
|
||||
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
|
||||
this {actionTarget.toLowerCase()}.
|
||||
{match({ role: recipient.role, actionTarget })
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to sign this field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to sign this document.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to approve this field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to approve this
|
||||
document.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to view this field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to mark this document as
|
||||
viewed.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to view this field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to view this document.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to assist with this
|
||||
field.
|
||||
</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>
|
||||
Your browser does not support passkeys, which is required to assist with this
|
||||
document.
|
||||
</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -178,10 +230,38 @@ export const DocumentSigningAuthPasskey = ({
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{/* Todo: Translate */}
|
||||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
|
||||
? 'You need to setup a passkey to mark this document as viewed.'
|
||||
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
|
||||
{match({ role: recipient.role, actionTarget })
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to sign this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to sign this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to approve this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to approve this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to view this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to mark this document as viewed.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to view this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to view this document.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
|
||||
<Trans>You need to setup a passkey to assist with this field.</Trans>
|
||||
))
|
||||
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
|
||||
<Trans>You need to setup a passkey to assist with this document.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -213,7 +293,9 @@ export const DocumentSigningAuthPasskey = ({
|
||||
name="passkeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Passkey</FormLabel>
|
||||
<FormLabel required>
|
||||
<Trans>Passkey</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
@@ -241,20 +323,24 @@ export const DocumentSigningAuthPasskey = ({
|
||||
|
||||
{formErrorCode && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Unauthorized</AlertTitle>
|
||||
<AlertTitle>
|
||||
<Trans>Unauthorized</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
We were unable to verify your details. Please try again or contact support
|
||||
<Trans>
|
||||
We were unable to verify your details. Please try again or contact support
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
Sign
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
@@ -23,8 +23,6 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningAuthPasswordProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
@@ -40,8 +38,6 @@ const ZPasswordAuthFormSchema = z.object({
|
||||
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
|
||||
|
||||
export const DocumentSigningAuthPassword = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
|
||||
if (
|
||||
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
|
||||
user?.email == recipient.email
|
||||
user?.email === recipient.email
|
||||
) {
|
||||
return {
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
|
||||
@@ -162,10 +162,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Automatically sign fields</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans>Automatically sign fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
<div className="max-w-[50ch] text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
When you sign a document, we can automatically fill in and sign the following fields
|
||||
|
||||
+8
-3
@@ -14,6 +14,7 @@ import {
|
||||
ZDocumentAccessAuthSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -68,7 +69,7 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
accessAuthOptions: ZDocumentAccessAuthSchema.optional(),
|
||||
});
|
||||
|
||||
@@ -76,7 +77,7 @@ type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
|
||||
const ZDirectRecipientFormSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: zEmail('Invalid email address'),
|
||||
});
|
||||
|
||||
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
|
||||
@@ -117,7 +118,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const recipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||
defaultValues: {
|
||||
values: {
|
||||
name: recipientPayload?.name ?? '',
|
||||
email: recipientPayload?.email ?? '',
|
||||
},
|
||||
@@ -157,6 +158,10 @@ export const DocumentSigningCompleteDialog = ({
|
||||
}
|
||||
|
||||
recipientOverridePayload = recipientForm.getValues();
|
||||
} else if (recipientPayload && recipientPayload.email && !recipient.email) {
|
||||
// Form is hidden because we have an email (e.g. from embed context),
|
||||
// but the DB recipient doesn't have one yet — send the override.
|
||||
recipientOverridePayload = recipientPayload;
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
|
||||
+57
-61
@@ -9,7 +9,7 @@ import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
@@ -130,69 +130,65 @@ export const DocumentSigningFieldContainer = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('[container-type:size]')}>
|
||||
<FieldRootContainer
|
||||
color={
|
||||
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
|
||||
}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
<FieldRootContainer
|
||||
color={getRecipientColorStyles(field.fieldMeta?.readOnly ? 'readOnly' : 0)}
|
||||
field={field}
|
||||
>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-[2px]"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => void onClearCheckBoxValues(type)}
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => void onClearCheckBoxValues(type)}
|
||||
>
|
||||
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
|
||||
sideOffset={2}
|
||||
>
|
||||
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{tooltipText && <p>{tooltipText}</p>}
|
||||
|
||||
<Trans>Remove</Trans>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||
field.fieldMeta?.label && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||
{
|
||||
'border border-border bg-foreground/5': !field.inserted,
|
||||
},
|
||||
{
|
||||
'border border-primary bg-documenso-200': field.inserted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.fieldMeta.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
|
||||
sideOffset={2}
|
||||
>
|
||||
{tooltipText && <p>{tooltipText}</p>}
|
||||
|
||||
<Trans>Remove</Trans>
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||
field.fieldMeta?.label && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||
{
|
||||
'bg-foreground/5 border-border border': !field.inserted,
|
||||
},
|
||||
{
|
||||
'bg-documenso-200 border-primary border': field.inserted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.fieldMeta.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
</div>
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useId, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
||||
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -78,7 +79,7 @@ export const DocumentSigningForm = ({
|
||||
[fields],
|
||||
);
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
||||
|
||||
+3
-1
@@ -122,7 +122,9 @@ export const DocumentSigningMobileWidget = () => {
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:hidden">
|
||||
<span>Powered by</span>
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+13
-10
@@ -22,6 +22,7 @@ import {
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
@@ -30,7 +31,6 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||
@@ -46,6 +46,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
|
||||
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
@@ -162,8 +163,6 @@ export const DocumentSigningPageViewV1 = ({
|
||||
: undefined;
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
const highestPageNumber = Math.max(...fields.map((field) => field.page));
|
||||
|
||||
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
|
||||
const hasPendingFields = pendingFields.length > 0;
|
||||
|
||||
@@ -275,10 +274,16 @@ export const DocumentSigningPageViewV1 = ({
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
key={document.envelopeItems[0]?.id}
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0]?.id,
|
||||
documentDataId: document.envelopeItems[0]?.documentData.id,
|
||||
version: 'current',
|
||||
token: recipient.token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -400,9 +405,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
||||
)}
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields
|
||||
.filter(
|
||||
(field) =>
|
||||
|
||||
+171
-118
@@ -1,15 +1,23 @@
|
||||
import { lazy, useMemo } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BanIcon,
|
||||
DownloadCloudIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PaperclipIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
@@ -23,6 +31,8 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
|
||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
import { EnvelopeSignerPageRenderer } from '~/components/general/envelope-signing/envelope-signer-page-renderer';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
|
||||
import { BrandingLogo } from '../branding-logo';
|
||||
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||
@@ -33,13 +43,11 @@ import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
|
||||
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
|
||||
const EnvelopeSignerPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'),
|
||||
);
|
||||
|
||||
export const DocumentSigningPageViewV2 = () => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
isDirectTemplate,
|
||||
envelope,
|
||||
@@ -57,6 +65,9 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
onDocumentRejected,
|
||||
} = useEmbedSigningContext() || {};
|
||||
|
||||
const { t } = useLingui();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
/**
|
||||
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||
*
|
||||
@@ -86,120 +97,159 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="embed--DocumentWidgetContainer hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4 lg:flex">
|
||||
<div className="px-4">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
|
||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Plural
|
||||
value={recipientFieldsRemaining.length}
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
/>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="absolute inset-y-0 left-0 bg-primary"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||
<EnvelopeSignerForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Quick Actions. */}
|
||||
{!isDirectTemplate && (
|
||||
<div className="embed--Actions space-y-3 px-4">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={envelope.id}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<PaperclipIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Attachments</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
onRejected={
|
||||
onDocumentRejected &&
|
||||
((reason) =>
|
||||
onDocumentRejected({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
}))
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start hover:text-destructive"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'embed--DocumentWidgetContainer hidden flex-shrink-0 flex-col border-r border-border bg-background transition-[width] duration-300 lg:flex',
|
||||
isSidebarCollapsed ? 'w-12' : 'w-80',
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={t`Expand sidebar`}
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
>
|
||||
<PanelLeftOpenIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed--DocumentWidgetFooter mt-auto">
|
||||
{/* Footer of left sidebar. */}
|
||||
{!isEmbed && (
|
||||
<div className="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
|
||||
className={cn(
|
||||
'flex flex-1 flex-col overflow-hidden py-4',
|
||||
isSidebarCollapsed && 'invisible w-0',
|
||||
)}
|
||||
>
|
||||
<div className="px-4">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
|
||||
<div className="ml-2 flex items-center gap-1">
|
||||
<span className="rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Plural
|
||||
value={recipientFieldsRemaining.length}
|
||||
one="1 Field Remaining"
|
||||
other="# Fields Remaining"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label={t`Collapse sidebar`}
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
>
|
||||
<PanelLeftCloseIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="absolute inset-y-0 left-0 bg-primary"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||
<EnvelopeSignerForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Quick Actions. */}
|
||||
{!isDirectTemplate && (
|
||||
<div className="embed--Actions space-y-3 px-4">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Actions</Trans>
|
||||
</h4>
|
||||
|
||||
<DocumentSigningAttachmentsPopover
|
||||
envelopeId={envelope.id}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<PaperclipIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Attachments</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={recipient.token}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
onRejected={
|
||||
onDocumentRejected &&
|
||||
((reason) =>
|
||||
onDocumentRejected({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
}))
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start hover:text-destructive"
|
||||
>
|
||||
<BanIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed--DocumentWidgetFooter mt-auto">
|
||||
{/* Footer of left sidebar. */}
|
||||
{!isEmbed && (
|
||||
<div className="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
||||
<div
|
||||
className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{envelopeItems.length > 1 && (
|
||||
@@ -228,15 +278,16 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
{/* Document View */}
|
||||
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||
{currentEnvelopeItem ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="signing"
|
||||
<EnvelopePdfViewer
|
||||
key={currentEnvelopeItem.id}
|
||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-sm text-foreground">
|
||||
<Trans>No documents found</Trans>
|
||||
<Trans>No document selected</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -252,7 +303,9 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
target="_blank"
|
||||
className="fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:block"
|
||||
>
|
||||
<span>Powered by</span>
|
||||
<span>
|
||||
<Trans>Powered by</Trans>
|
||||
</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
@@ -83,6 +86,54 @@ export interface EnvelopeSigningProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject prefilled date fields for the current recipient.
|
||||
*
|
||||
* The dates are filled in correctly when the recipient "completes" the document.
|
||||
*/
|
||||
const prefillDateFields = (data: EnvelopeForSigningResponse): EnvelopeForSigningResponse => {
|
||||
const { timezone, dateFormat } = data.envelope.documentMeta;
|
||||
|
||||
const formattedDate = DateTime.now()
|
||||
.setZone(timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
||||
.toFormat(dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
|
||||
const prefillField = <
|
||||
T extends { type: FieldType; inserted: boolean; customText: string; fieldMeta: unknown },
|
||||
>(
|
||||
field: T,
|
||||
): T => {
|
||||
if (field.type !== FieldType.DATE || field.inserted) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
customText: formattedDate,
|
||||
inserted: true,
|
||||
fieldMeta: {
|
||||
...(typeof field.fieldMeta === 'object' ? field.fieldMeta : {}),
|
||||
readOnly: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
...data,
|
||||
envelope: {
|
||||
...data.envelope,
|
||||
recipients: data.envelope.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
fields: recipient.fields.map(prefillField),
|
||||
})),
|
||||
},
|
||||
recipient: {
|
||||
...data.recipient,
|
||||
fields: data.recipient.fields.map(prefillField),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const EnvelopeSigningProvider = ({
|
||||
fullName: initialFullName,
|
||||
email: initialEmail,
|
||||
@@ -90,7 +141,7 @@ export const EnvelopeSigningProvider = ({
|
||||
envelopeData: initialEnvelopeData,
|
||||
children,
|
||||
}: EnvelopeSigningProviderProps) => {
|
||||
const [envelopeData, setEnvelopeData] = useState(initialEnvelopeData);
|
||||
const [envelopeData, setEnvelopeData] = useState(() => prefillDateFields(initialEnvelopeData));
|
||||
|
||||
const { envelope, recipient } = envelopeData;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Paperclip, Plus, X } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -36,6 +37,7 @@ const ZAttachmentFormSchema = z.object({
|
||||
|
||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||
|
||||
// NOTE: REMEMBER TO UPDATE THE EMBEDDED VERSION OF THIS COMPONENT TOO.
|
||||
export const DocumentAttachmentsPopover = ({
|
||||
envelopeId,
|
||||
buttonClassName,
|
||||
@@ -49,9 +51,16 @@ export const DocumentAttachmentsPopover = ({
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||
envelopeId,
|
||||
});
|
||||
const { data: attachments } = trpc.envelope.attachment.find.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
},
|
||||
{
|
||||
// Note: The invalidation of the query is manually handled by the onSuccess
|
||||
// callbacks below for create and delete mutations.
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: createAttachment, isPending: isCreating } =
|
||||
trpc.envelope.attachment.create.useMutation({
|
||||
@@ -143,7 +152,7 @@ export const DocumentAttachmentsPopover = ({
|
||||
<h4 className="font-medium">
|
||||
<Trans>Attachments</Trans>
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>Add links to relevant documents or resources.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -153,7 +162,7 @@ export const DocumentAttachmentsPopover = ({
|
||||
{attachments?.data.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="border-border flex items-center justify-between rounded-md border p-2"
|
||||
className="flex items-center justify-between rounded-md border border-border p-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
||||
@@ -161,7 +170,7 @@ export const DocumentAttachmentsPopover = ({
|
||||
href={attachment.data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
|
||||
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
|
||||
>
|
||||
{attachment.data}
|
||||
</a>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -25,36 +27,15 @@ export const DocumentAuditLogDownloadButton = ({
|
||||
|
||||
const onDownloadAuditLogsClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadAuditLogs({ documentId });
|
||||
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
const buffer = new Uint8Array(base64.decode(data));
|
||||
const blob = new Blob([buffer], { type: 'application/pdf' });
|
||||
|
||||
downloadFile({
|
||||
data: blob,
|
||||
filename: `${envelopeTitle} - Audit Logs.pdf`,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
|
||||
+9
-28
@@ -4,6 +4,8 @@ import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -29,36 +31,15 @@ export const DocumentCertificateDownloadButton = ({
|
||||
|
||||
const onDownloadCertificatesClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadCertificate({ documentId });
|
||||
const { data, envelopeTitle } = await downloadCertificate({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
const buffer = new Uint8Array(base64.decode(data));
|
||||
const blob = new Blob([buffer], { type: 'application/pdf' });
|
||||
|
||||
downloadFile({
|
||||
data: blob,
|
||||
filename: `${envelopeTitle} - Certificate.pdf`,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -21,15 +22,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
import { EnvelopeGenericPageRenderer } from '../envelope-editor/envelope-generic-page-renderer';
|
||||
|
||||
export type DocumentCertificateQRViewProps = {
|
||||
documentId: number;
|
||||
@@ -104,11 +103,13 @@ export const DocumentCertificateQRView = ({
|
||||
|
||||
{internalVersion === 2 ? (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={{
|
||||
envelopeItems,
|
||||
id: envelopeItems[0].envelopeId,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
}}
|
||||
envelopeItems={envelopeItems}
|
||||
token={token}
|
||||
>
|
||||
<DocumentCertificateQrV2
|
||||
@@ -150,10 +151,16 @@ export const DocumentCertificateQRView = ({
|
||||
|
||||
<div className="mt-12 w-full">
|
||||
<PDFViewerLazy
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
key={envelopeItems[0]?.id}
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: envelopeItems[0]?.envelopeId,
|
||||
envelopeItemId: envelopeItems[0]?.id,
|
||||
documentDataId: envelopeItems[0]?.documentDataId,
|
||||
version: 'current',
|
||||
token,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -175,7 +182,7 @@ const DocumentCertificateQrV2 = ({
|
||||
formattedDate,
|
||||
token,
|
||||
}: DocumentCertificateQrV2Props) => {
|
||||
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
||||
const { envelopeItems } = useCurrentEnvelopeRender();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-start">
|
||||
@@ -210,7 +217,11 @@ const DocumentCertificateQrV2 = ({
|
||||
<div className="mt-12 w-full">
|
||||
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
|
||||
|
||||
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
<EnvelopePdfViewer
|
||||
scrollParentRef="window"
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@@ -27,10 +28,10 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentEditFormProps = {
|
||||
@@ -441,10 +442,16 @@ export const DocumentEditForm = ({
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerLazy
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
key={document.envelopeItems[0]?.id}
|
||||
data={getDocumentDataUrlForPdfViewer({
|
||||
envelopeId: document.envelopeId,
|
||||
envelopeItemId: document.envelopeItems[0]?.id,
|
||||
documentDataId: initialDocument.documentDataId,
|
||||
version: 'current',
|
||||
token: undefined,
|
||||
presignToken: undefined,
|
||||
})}
|
||||
scrollParentRef="window"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
FileOutputIcon,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
ScrollTextIcon,
|
||||
Share,
|
||||
Trash2,
|
||||
@@ -18,8 +19,12 @@ import { Link, useNavigate } from 'react-router';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import {
|
||||
getEnvelopeItemPermissions,
|
||||
mapSecondaryIdToDocumentId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -28,12 +33,13 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -43,14 +49,14 @@ export type DocumentPageViewDropdownProps = {
|
||||
|
||||
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
|
||||
const { user } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
|
||||
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
@@ -62,14 +68,16 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
const isCurrentTeamDocument = team && envelope.teamId === team.id;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const { canTitleBeChanged } = getEnvelopeItemPermissions(envelope, []);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
|
||||
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="end" forceMount>
|
||||
@@ -86,6 +94,13 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canManageDocument && canTitleBeChanged && (
|
||||
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Rename</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
@@ -108,15 +123,42 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
<EnvelopeDuplicateDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={isDeleted}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
<EnvelopeDeleteDialog
|
||||
id={envelope.id}
|
||||
type={EnvelopeType.DOCUMENT}
|
||||
status={envelope.status}
|
||||
title={envelope.title}
|
||||
canManageDocument={canManageDocument}
|
||||
onDelete={() => {
|
||||
void navigate(documentsPath);
|
||||
}}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild disabled={isDeleted} onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Share</Trans>
|
||||
@@ -159,26 +201,21 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<DocumentDeleteDialog
|
||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
status={envelope.status}
|
||||
documentTitle={envelope.title}
|
||||
open={isDeleteDialogOpen}
|
||||
canManageDocument={canManageDocument}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={() => {
|
||||
void navigate(documentsPath);
|
||||
}}
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={envelope.id}
|
||||
open={isSaveAsTemplateDialogOpen}
|
||||
onOpenChange={setSaveAsTemplateDialogOpen}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DocumentDuplicateDialog
|
||||
id={envelope.id}
|
||||
token={recipient?.token}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<EnvelopeRenameDialog
|
||||
id={envelope.id}
|
||||
initialTitle={envelope.title}
|
||||
open={isRenameDialogOpen}
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
onSuccess={async () => {
|
||||
await trpcUtils.envelope.get.invalidate();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,19 +9,21 @@ import {
|
||||
AlertTriangle,
|
||||
CheckIcon,
|
||||
Clock,
|
||||
Clock8Icon,
|
||||
MailIcon,
|
||||
MailOpenIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { formatSigningLink, isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -44,7 +46,7 @@ export const DocumentPageViewRecipients = ({
|
||||
envelope,
|
||||
documentRootPath,
|
||||
}: DocumentPageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { _, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -66,9 +68,9 @@ export const DocumentPageViewRecipients = ({
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<h1 className="font-medium text-foreground">
|
||||
<Trans>Recipients</Trans>
|
||||
</h1>
|
||||
|
||||
@@ -87,7 +89,7 @@ export const DocumentPageViewRecipients = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="text-muted-foreground divide-y border-t">
|
||||
<ul className="divide-y border-t text-muted-foreground">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -98,9 +100,9 @@ export const DocumentPageViewRecipients = ({
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
@@ -154,12 +156,41 @@ export const DocumentPageViewRecipients = ({
|
||||
)}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
isRecipientExpired(recipient) && (
|
||||
<Badge variant="destructive">
|
||||
<Clock8Icon className="mr-1 h-3 w-3" />
|
||||
<Trans>Expired</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
!isRecipientExpired(recipient) &&
|
||||
(recipient.expiresAt ? (
|
||||
<PopoverHover
|
||||
trigger={
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Expires{' '}
|
||||
{recipient.expiresAt
|
||||
? i18n.date(recipient.expiresAt, DateTime.DATETIME_MED)
|
||||
: 'N/A'}
|
||||
</Trans>
|
||||
</p>
|
||||
</PopoverHover>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
))}
|
||||
|
||||
{envelope.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||
@@ -175,7 +206,7 @@ export const DocumentPageViewRecipients = ({
|
||||
<Trans>Reason for rejection: </Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{recipient.rejectionReason}
|
||||
</p>
|
||||
</PopoverHover>
|
||||
@@ -183,7 +214,8 @@ export const DocumentPageViewRecipients = ({
|
||||
|
||||
{envelope.status === DocumentStatus.PENDING &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC && (
|
||||
recipient.role !== RecipientRole.CC &&
|
||||
!isRecipientExpired(recipient) && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
@@ -11,6 +10,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -29,7 +29,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentRecipientLinkCopyDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const DocumentRecipientLinkCopyDialog = ({
|
||||
@@ -88,7 +88,7 @@ export const DocumentRecipientLinkCopyDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="text-muted-foreground divide-y rounded-lg border">
|
||||
<ul className="divide-y rounded-lg border text-muted-foreground">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -99,9 +99,9 @@ export const DocumentRecipientLinkCopyDialog = ({
|
||||
<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>}
|
||||
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type { FileRejection } from 'react-dropzone';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -11,13 +12,13 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
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 { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
|
||||
import {
|
||||
@@ -162,10 +163,10 @@ export const DocumentUploadButtonLegacy = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
toast({
|
||||
title: _(msg`Your document failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
description: _(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Paperclip, Plus, X } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EmbeddedEditorAttachmentPopoverProps = {
|
||||
buttonClassName?: string;
|
||||
buttonSize?: 'sm' | 'default';
|
||||
};
|
||||
|
||||
const ZAttachmentFormSchema = z.object({
|
||||
label: z.string().min(1, 'Label is required'),
|
||||
url: z.string().url('Must be a valid URL'),
|
||||
});
|
||||
|
||||
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||
|
||||
// NOTE: REMEMBER TO UPDATE THE NON-EMBEDDED VERSION OF THIS COMPONENT TOO.
|
||||
export const EmbeddedEditorAttachmentPopover = ({
|
||||
buttonClassName,
|
||||
buttonSize,
|
||||
}: EmbeddedEditorAttachmentPopoverProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
const attachments = envelope.attachments ?? [];
|
||||
|
||||
const form = useForm<TAttachmentFormSchema>({
|
||||
resolver: zodResolver(ZAttachmentFormSchema),
|
||||
defaultValues: {
|
||||
label: '',
|
||||
url: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: TAttachmentFormSchema) => {
|
||||
setLocalEnvelope({
|
||||
attachments: [
|
||||
...attachments,
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'link',
|
||||
label: data.label,
|
||||
data: data.url,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
form.reset();
|
||||
setIsAdding(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Attachment added successfully.`),
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteAttachment = (id: string) => {
|
||||
setLocalEnvelope({
|
||||
attachments: attachments.filter((a) => a.id !== id),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Attachment removed successfully.`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
|
||||
<span>
|
||||
<Trans>Attachments</Trans>
|
||||
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-96" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
<Trans>Attachments</Trans>
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>Add links to relevant documents or resources.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center justify-between rounded-md border border-border p-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
||||
<a
|
||||
href={attachment.data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
|
||||
>
|
||||
{attachment.data}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteAttachment(attachment.id)}
|
||||
className="ml-2 h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Trans>Add Attachment</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAdding && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={_(msg`Label`)} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type="url" placeholder={_(msg`URL`)} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="flex-1">
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
+11
-26
@@ -23,7 +23,7 @@ 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 { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
|
||||
@@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
|
||||
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
|
||||
@@ -262,9 +253,10 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
const selectedRecipientColor = useMemo(() => {
|
||||
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
||||
}, [selectedRecipientId, getRecipientColorKey]);
|
||||
const selectedRecipientStyles = useMemo(
|
||||
() => getRecipientColorStyles(getRecipientColorKey(selectedRecipientId ?? -1)),
|
||||
[selectedRecipientId, getRecipientColorKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -278,22 +270,15 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
onMouseDown={() => setSelectedField(field.type)}
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
className={cn(
|
||||
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
||||
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
|
||||
selectedRecipientStyles.fieldButton,
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
|
||||
field.className,
|
||||
{
|
||||
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
||||
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
|
||||
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
|
||||
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
|
||||
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
|
||||
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
|
||||
},
|
||||
selectedRecipientStyles.fieldButtonText,
|
||||
)}
|
||||
>
|
||||
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
||||
@@ -306,8 +291,8 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
|
||||
selectedRecipientStyles.base,
|
||||
selectedField === FieldType.SIGNATURE && 'font-signature',
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
|
||||
+40
-41
@@ -10,7 +10,10 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
|
||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
type PageRenderData,
|
||||
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,
|
||||
@@ -25,7 +28,7 @@ import { CommandDialog } from '@documenso/ui/primitives/command';
|
||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
|
||||
|
||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
@@ -37,34 +40,22 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
|
||||
pageData,
|
||||
);
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
const { scale, pageNumber } = pageData;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
editorFields.localFields.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[editorFields.localFields, pageContext.pageNumber],
|
||||
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
|
||||
);
|
||||
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
const { current: container } = canvasElement;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDragEvent = event.type === 'dragend';
|
||||
|
||||
const fieldGroup = event.target as Konva.Group;
|
||||
@@ -344,7 +335,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
// Create a field if no items are selected or the size is too small.
|
||||
if (
|
||||
selectedFieldGroups.length === 0 &&
|
||||
canvasElement.current &&
|
||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||
editorFields.selectedRecipient &&
|
||||
@@ -434,15 +424,31 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
// Reconcile selection state with live field nodes after flush/sync updates.
|
||||
const liveSelectedFieldGroups = selectedKonvaFieldGroups.filter((fieldGroup) => {
|
||||
if (!fieldGroup.getStage() || !fieldGroup.getParent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return localPageFields.some((field) => field.formId === fieldGroup.id());
|
||||
});
|
||||
|
||||
if (liveSelectedFieldGroups.length !== selectedKonvaFieldGroups.length) {
|
||||
setSelectedFields(liveSelectedFieldGroups);
|
||||
}
|
||||
|
||||
// Rerender the transformer
|
||||
interactiveTransformer.current?.forceUpdate();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields]);
|
||||
}, [localPageFields, selectedKonvaFieldGroups]);
|
||||
|
||||
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[];
|
||||
const fieldGroups = nodes.filter(
|
||||
(node) =>
|
||||
node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
|
||||
) as Konva.Group[];
|
||||
|
||||
interactiveTransformer.current?.nodes(fieldGroups);
|
||||
setSelectedKonvaFieldGroups(fieldGroups);
|
||||
@@ -515,7 +521,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
|
||||
removePendingField();
|
||||
|
||||
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -530,7 +536,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageContext.pageNumber,
|
||||
page: pageNumber,
|
||||
type,
|
||||
positionX: fieldX,
|
||||
positionY: fieldY,
|
||||
@@ -559,10 +565,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
<>
|
||||
{selectedKonvaFieldGroups.length > 0 &&
|
||||
interactiveTransformer.current &&
|
||||
!isFieldChanging && (
|
||||
@@ -624,17 +627,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
handleDuplicateSelectedFields: () => void;
|
||||
@@ -674,6 +669,10 @@ const FieldActionButtons = ({
|
||||
selectedFieldFormId.includes(field.formId),
|
||||
);
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = envelope.recipients.find(
|
||||
(recipient) => recipient.id === fields[0].recipientId,
|
||||
);
|
||||
@@ -689,7 +688,7 @@ const FieldActionButtons = ({
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [editorFields.localFields]);
|
||||
}, [editorFields.localFields, envelope.recipients, selectedFieldFormId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" {...props}>
|
||||
|
||||
+118
-54
@@ -1,17 +1,18 @@
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { Link, useRevalidator, useSearchParams } from 'react-router';
|
||||
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
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 { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import {
|
||||
FIELD_META_DEFAULT_VALUES,
|
||||
@@ -27,15 +28,17 @@ import {
|
||||
type TSignatureFieldMeta,
|
||||
type TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
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 { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||
import { EnvelopeItemEditDialog } from '~/components/dialogs/envelope-item-edit-dialog';
|
||||
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';
|
||||
@@ -46,16 +49,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
|
||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||
|
||||
const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||
@@ -75,7 +76,9 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
@@ -85,6 +88,11 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
[editorFields.selectedField],
|
||||
@@ -97,14 +105,10 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,12 +160,47 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
<div
|
||||
className="flex h-full w-full flex-col overflow-y-auto px-2"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
<EnvelopeRendererFileSelector
|
||||
className="px-0"
|
||||
fields={editorFields.localFields}
|
||||
renderItemAction={
|
||||
editorConfig.envelopeItems !== null &&
|
||||
editorConfig.envelopeItems.allowReplace &&
|
||||
envelopeItemPermissions.canFileBeChanged
|
||||
? (item) => (
|
||||
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0',
|
||||
{ 'bg-green-500': currentEnvelopeItem?.id === item.id },
|
||||
)}
|
||||
/>
|
||||
<EnvelopeItemEditDialog
|
||||
envelopeItem={item}
|
||||
allowConfigureTitle={editorConfig.envelopeItems?.allowConfigureTitle ?? false}
|
||||
trigger={
|
||||
<span
|
||||
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`envelope-item-edit-button-${item.id}`}
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex flex-col items-center justify-center">
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
{envelope.recipients.length === 0 && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
@@ -176,18 +215,17 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`${relativePath.editorPath}`}>
|
||||
<Trans>Add Recipients</Trans>
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
|
||||
<Trans>Add Recipients</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
@@ -249,36 +287,40 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={onDetectClick}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
{editorConfig.fields?.allowAIDetection && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={onDetectClick}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
onOpenChange={setIsAiEnableDialogOpen}
|
||||
onEnabled={onAiFeaturesEnabled}
|
||||
/>
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
onOpenChange={setIsAiEnableDialogOpen}
|
||||
onEnabled={onAiFeaturesEnabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
@@ -295,20 +337,42 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
|
||||
{selectedField.id && (
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Field ID:</Trans>
|
||||
</span>{' '}
|
||||
{selectedField.id}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">Pos X: </span>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Recipient ID:</Trans>
|
||||
</span>{' '}
|
||||
{selectedField.recipientId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Pos X:</Trans>
|
||||
</span>{' '}
|
||||
{selectedField.positionX.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">Pos Y: </span>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Pos Y:</Trans>
|
||||
</span>{' '}
|
||||
{selectedField.positionY.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">Width: </span>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Width:</Trans>
|
||||
</span>{' '}
|
||||
{selectedField.width.toFixed(2)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="min-w-12 text-muted-foreground">Height: </span>
|
||||
<span className="min-w-12 text-muted-foreground">
|
||||
<Trans>Height:</Trans>
|
||||
</span>{' '}
|
||||
{selectedField.height.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
Building2Icon,
|
||||
Globe2Icon,
|
||||
LockIcon,
|
||||
RefreshCwIcon,
|
||||
@@ -12,7 +15,10 @@ 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 {
|
||||
getEnvelopeItemPermissions,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
@@ -22,6 +28,7 @@ import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redist
|
||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||
import { EmbeddedEditorAttachmentPopover } from '~/components/general/document/embedded-editor-attachment-popover';
|
||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||
|
||||
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
|
||||
@@ -30,21 +37,62 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
export default function EnvelopeEditorHeader() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
|
||||
useCurrentEnvelopeEditor();
|
||||
const {
|
||||
envelope,
|
||||
isDocument,
|
||||
isTemplate,
|
||||
isEmbedded,
|
||||
updateEnvelope,
|
||||
autosaveError,
|
||||
relativePath,
|
||||
editorConfig,
|
||||
flushAutosave,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
|
||||
const {
|
||||
embedded,
|
||||
general: { allowConfigureEnvelopeTitle },
|
||||
actions: { allowAttachments, allowDistributing },
|
||||
} = editorConfig;
|
||||
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
);
|
||||
|
||||
const handleCreateEmbeddedEnvelope = async () => {
|
||||
const latestEnvelope = await flushAutosave();
|
||||
|
||||
embedded?.onCreate?.(latestEnvelope);
|
||||
};
|
||||
|
||||
const handleUpdateEmbeddedEnvelope = async () => {
|
||||
const latestEnvelope = await flushAutosave();
|
||||
|
||||
embedded?.onUpdate?.(latestEnvelope);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/">
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
</Link>
|
||||
{editorConfig.embedded?.customBrandingLogo ? (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${envelope.teamId}`}
|
||||
alt="Logo"
|
||||
className="h-6 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
dataTestId="envelope-title-input"
|
||||
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
|
||||
value={envelope.title}
|
||||
onChange={(title) => {
|
||||
updateEnvelope({
|
||||
@@ -58,12 +106,19 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<>
|
||||
{envelope.templateType === 'PRIVATE' ? (
|
||||
{envelope.templateType === 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>
|
||||
) : (
|
||||
)}
|
||||
{envelope.templateType === TemplateType.ORGANISATION && (
|
||||
<Badge variant="orange">
|
||||
<Building2Icon className="mr-2 size-4" />
|
||||
<Trans>Organisation Template</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{envelope.templateType === TemplateType.PUBLIC && (
|
||||
<Badge variant="default">
|
||||
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Public Template</Trans>
|
||||
@@ -127,54 +182,75 @@ export default function EnvelopeEditorHeader() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
|
||||
{allowAttachments &&
|
||||
(isEmbedded ? (
|
||||
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
|
||||
) : (
|
||||
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
|
||||
))}
|
||||
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isDocument && (
|
||||
<>
|
||||
<EnvelopeDistributeDialog
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
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
|
||||
envelopeId={envelope.id}
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
||||
recipients={envelope.recipients}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
{editorConfig.settings && (
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<Trans>Use Template</Trans>
|
||||
<Button variant="outline" size="sm">
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
|
||||
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
|
||||
<>
|
||||
<EnvelopeDistributeDialog
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
|
||||
<TemplateUseDialog
|
||||
envelopeId={envelope.id}
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
templateSigningOrder={envelope.documentMeta?.signingOrder}
|
||||
recipients={envelope.recipients}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<Trans>Use Template</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))
|
||||
|
||||
.otherwise(() => null)}
|
||||
|
||||
{embedded?.mode === 'create' && (
|
||||
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
|
||||
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{embedded?.mode === 'edit' && (
|
||||
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
|
||||
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
+44
-24
@@ -1,6 +1,6 @@
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { faker } from '@faker-js/faker/locale/en';
|
||||
import type { Faker } from '@faker-js/faker';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FieldType, SigningStatus } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
@@ -11,33 +11,46 @@ import {
|
||||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
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 { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
|
||||
);
|
||||
|
||||
// Todo: Envelopes - Dynamically import faker
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, editorConfig } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||
'recipient',
|
||||
);
|
||||
|
||||
const [fakerInstance, setFakerInstance] = useState<Faker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void import('@faker-js/faker/locale/en').then((mod) => {
|
||||
setFakerInstance(mod.faker);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fieldsWithPlaceholders = useMemo(() => {
|
||||
if (!fakerInstance) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const faker = fakerInstance;
|
||||
return fields.map((field) => {
|
||||
const fieldMeta = ZFieldAndMetaSchema.parse(field);
|
||||
|
||||
@@ -188,7 +201,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
.exhaustive(),
|
||||
};
|
||||
});
|
||||
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
|
||||
}, [fields, envelope, envelope.recipients, envelope.documentMeta, fakerInstance]);
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
@@ -200,37 +213,44 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
// Override the parent renderer provider so we can inject custom fields.
|
||||
return (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={fieldsWithPlaceholders}
|
||||
recipients={envelope.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
}))}
|
||||
presignToken={editorConfig?.embedded?.presignToken}
|
||||
overrideSettings={{
|
||||
mode: 'export',
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
<div
|
||||
className="flex h-full w-full flex-col overflow-y-auto px-2"
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
||||
|
||||
<Alert variant="warning" className="mx-auto 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
|
||||
+188
-206
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
DragDropContext,
|
||||
@@ -7,32 +7,26 @@ import {
|
||||
Droppable,
|
||||
type SensorAPI,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { plural } 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, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useFieldArray, useWatch } from 'react-hook-form';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
||||
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 { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { 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,
|
||||
@@ -67,26 +61,15 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZEnvelopeRecipientsForm = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
|
||||
|
||||
export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||
const {
|
||||
envelope,
|
||||
setRecipientsDebounced,
|
||||
updateEnvelope,
|
||||
editorRecipients,
|
||||
isEmbedded,
|
||||
editorConfig,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
@@ -94,7 +77,9 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { user } = useSession();
|
||||
const { sessionData } = useOptionalSession();
|
||||
|
||||
const user = sessionData?.user;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
@@ -145,7 +130,6 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
const { recipients, fields } = envelope;
|
||||
@@ -155,48 +139,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
query: debouncedRecipientSearchQuery,
|
||||
},
|
||||
{
|
||||
enabled: debouncedRecipientSearchQuery.length > 1,
|
||||
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
const { form } = editorRecipients;
|
||||
|
||||
const recipientHasAuthSettings = useMemo(() => {
|
||||
const recipientHasAuthOptions = recipients.find((recipient) => {
|
||||
@@ -588,7 +538,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
||||
const validatedFormValues = ZEditorRecipientsFormSchema.safeParse(formValues);
|
||||
|
||||
if (!validatedFormValues.success) {
|
||||
return;
|
||||
@@ -652,42 +602,50 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<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>
|
||||
<CardTitle>
|
||||
<Trans>Recipients</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">
|
||||
<Trans>Add recipients to your document</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={onDetectRecipientsClick}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{editorConfig.recipients?.allowAIDetection && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={onDetectRecipientsClick}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
{team.preferences.aiFeaturesEnabled ? (
|
||||
<Trans>Detect recipients with AI</Trans>
|
||||
) : (
|
||||
<Trans>Enable AI detection</Trans>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipContent>
|
||||
{team.preferences.aiFeaturesEnabled ? (
|
||||
<Trans>Detect recipients with AI</Trans>
|
||||
) : (
|
||||
<Trans>Enable AI detection</Trans>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-row items-center"
|
||||
size="sm"
|
||||
disabled={isSubmitting || isUserAlreadyARecipient}
|
||||
onClick={() => onAddSelfSigner()}
|
||||
>
|
||||
<Trans>Add Myself</Trans>
|
||||
</Button>
|
||||
{!isEmbedded && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-row items-center"
|
||||
size="sm"
|
||||
disabled={isSubmitting || isUserAlreadyARecipient}
|
||||
onClick={() => onAddSelfSigner()}
|
||||
>
|
||||
<Trans>Add Myself</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -704,26 +662,32 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||
<Form {...form}>
|
||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="showAdvancedRecipientSettings"
|
||||
checked={showAdvancedSettings}
|
||||
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<div
|
||||
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
|
||||
hidden:
|
||||
!editorConfig.recipients?.allowConfigureSigningOrder &&
|
||||
!organisation.organisationClaim.flags.cfr21,
|
||||
})}
|
||||
>
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="showAdvancedRecipientSettings"
|
||||
checked={showAdvancedSettings}
|
||||
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
htmlFor="showAdvancedRecipientSettings"
|
||||
>
|
||||
<Trans>Show advanced settings</Trans>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
htmlFor="showAdvancedRecipientSettings"
|
||||
>
|
||||
<Trans>Show advanced settings</Trans>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editorConfig.recipients?.allowConfigureSigningOrder && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signingOrder"
|
||||
@@ -782,73 +746,78 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</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>
|
||||
{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>
|
||||
<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="ml-1 cursor-help text-muted-foreground">
|
||||
<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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<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) => (
|
||||
<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) => {
|
||||
const isDirectRecipient =
|
||||
envelope.type === EnvelopeType.TEMPLATE &&
|
||||
envelope.directLink !== null &&
|
||||
signer.id === envelope.directLink.directTemplateRecipientId;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||
draggableId={signer['nativeId']}
|
||||
@@ -953,7 +922,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
isDirectRecipient
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
@@ -999,7 +969,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
isDirectRecipient
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
@@ -1033,6 +1004,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<FormControl>
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
hideAssistantRole={
|
||||
!editorConfig.recipients?.allowAssistantRole
|
||||
}
|
||||
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
|
||||
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
|
||||
hideApproverRole={
|
||||
!editorConfig.recipients?.allowApproverRole
|
||||
}
|
||||
isAssistantEnabled={isSigningOrderSequential}
|
||||
onValueChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
@@ -1062,7 +1041,8 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
signers.length === 1
|
||||
signers.length === 1 ||
|
||||
isDirectRecipient
|
||||
}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
>
|
||||
@@ -1105,21 +1085,21 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
{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>
|
||||
<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>
|
||||
|
||||
<SigningOrderConfirmation
|
||||
open={showSigningOrderConfirmation}
|
||||
@@ -1127,13 +1107,15 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
|
||||
<AiRecipientDetectionDialog
|
||||
open={isAiDialogOpen}
|
||||
onOpenChange={onAiDialogOpenChange}
|
||||
onComplete={onAiDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
{editorConfig.recipients?.allowAIDetection && (
|
||||
<AiRecipientDetectionDialog
|
||||
open={isAiDialogOpen}
|
||||
onOpenChange={onAiDialogOpenChange}
|
||||
onComplete={onAiDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
|
||||
export const EnvelopeEditorRenderProviderWrapper = ({
|
||||
children,
|
||||
token,
|
||||
presignedToken,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
token?: string;
|
||||
presignedToken?: string;
|
||||
}) => {
|
||||
const { envelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
return (
|
||||
<EnvelopeRenderProvider
|
||||
version="current"
|
||||
envelope={envelope}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
token={token}
|
||||
presignToken={presignedToken}
|
||||
>
|
||||
{children}
|
||||
</EnvelopeRenderProvider>
|
||||
);
|
||||
};
|
||||
+509
-334
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ export type EnvelopeItemTitleInputProps = {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
dataTestId?: string;
|
||||
};
|
||||
|
||||
export const EnvelopeItemTitleInput = ({
|
||||
@@ -17,6 +18,7 @@ export const EnvelopeItemTitleInput = ({
|
||||
className,
|
||||
placeholder,
|
||||
disabled,
|
||||
dataTestId,
|
||||
}: EnvelopeItemTitleInputProps) => {
|
||||
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
|
||||
const [isError, setIsError] = useState(false);
|
||||
@@ -63,6 +65,7 @@ export const EnvelopeItemTitleInput = ({
|
||||
{envelopeItemTitle || placeholder}
|
||||
</span>
|
||||
<input
|
||||
data-testid={dataTestId}
|
||||
data-1p-ignore
|
||||
autoComplete="off"
|
||||
ref={inputRef}
|
||||
@@ -72,7 +75,7 @@ export const EnvelopeItemTitleInput = ({
|
||||
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',
|
||||
'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
|
||||
className,
|
||||
{
|
||||
'outline-red-500': isError,
|
||||
|
||||
+341
-81
@@ -1,26 +1,26 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { msg, plural } 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 { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { Link } from 'react-router';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2Icon, PencilIcon, XIcon } from 'lucide-react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import {
|
||||
useCurrentEnvelopeEditor,
|
||||
useDebounceFunction,
|
||||
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
|
||||
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -42,17 +42,30 @@ type LocalFile = {
|
||||
title: string;
|
||||
envelopeItemId: string | null;
|
||||
isUploading: boolean;
|
||||
isReplacing: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorUploadPage = () => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
|
||||
const { t, i18n } = useLingui();
|
||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
setLocalEnvelope,
|
||||
editorFields,
|
||||
editorConfig,
|
||||
isEmbedded,
|
||||
navigateToStep,
|
||||
registerExternalFlush,
|
||||
registerPendingMutation,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
|
||||
const { envelopeItems: uploadConfig } = editorConfig;
|
||||
|
||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
|
||||
envelope.envelopeItems
|
||||
.sort((a, b) => a.order - b.order)
|
||||
@@ -61,10 +74,36 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
title: item.title,
|
||||
envelopeItemId: item.id,
|
||||
isUploading: false,
|
||||
isReplacing: false,
|
||||
isError: false,
|
||||
})),
|
||||
);
|
||||
|
||||
const replacingItemIdRef = useRef<string | null>(null);
|
||||
|
||||
const { open: openReplaceFilePicker, getInputProps: getReplaceInputProps } = useDropzone({
|
||||
accept: { 'application/pdf': ['.pdf'] },
|
||||
maxFiles: 1,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
multiple: false,
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
noDrag: true,
|
||||
onDrop: (acceptedFiles) => {
|
||||
const file = acceptedFiles[0];
|
||||
const replacingItemId = replacingItemIdRef.current;
|
||||
|
||||
if (file && replacingItemId) {
|
||||
void onReplacePdf(replacingItemId, file);
|
||||
replacingItemIdRef.current = null;
|
||||
}
|
||||
},
|
||||
onDropRejected: (fileRejections) => void onFileDropRejected(fileRejections),
|
||||
onFileDialogCancel: () => {
|
||||
replacingItemIdRef.current = null;
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
|
||||
trpc.envelope.item.createMany.useMutation({
|
||||
onSuccess: ({ data }) => {
|
||||
@@ -97,23 +136,71 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const canItemsBeModified = useMemo(
|
||||
() => canEnvelopeItemsBeModified(envelope, envelope.recipients),
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
);
|
||||
|
||||
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
|
||||
onSuccess: ({ data, fields }) => {
|
||||
// Update the envelope item with the new documentDataId.
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === data.id ? { ...item, documentDataId: data.documentDataId } : item,
|
||||
),
|
||||
});
|
||||
|
||||
// When fields were created or deleted during the replacement,
|
||||
// the server returns the full updated field list.
|
||||
if (fields) {
|
||||
setLocalEnvelope({ fields });
|
||||
editorFields.resetForm(fields);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
}));
|
||||
const newUploadingFiles: (LocalFile & {
|
||||
file: File;
|
||||
data: TEditorEnvelope['envelopeItems'][number]['data'] | null;
|
||||
})[] = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
|
||||
title: file.name,
|
||||
file,
|
||||
isUploading: isEmbedded ? false : true,
|
||||
isReplacing: false,
|
||||
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
|
||||
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
|
||||
isError: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
||||
|
||||
// Directly commit the files for embedded documents since those are not uploaded
|
||||
// until the end of the embedded flow.
|
||||
if (isEmbedded) {
|
||||
setLocalEnvelope({
|
||||
envelopeItems: [
|
||||
...envelope.envelopeItems,
|
||||
...newUploadingFiles.map((file) => ({
|
||||
id: file.envelopeItemId!,
|
||||
title: file.title,
|
||||
order: envelope.envelopeItems.length + 1,
|
||||
envelopeId: envelope.id,
|
||||
data: file.data!,
|
||||
documentDataId: '',
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TCreateEnvelopeItemsPayload;
|
||||
@@ -126,7 +213,11 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
const { data } = await createEnvelopeItems(formData).catch((error) => {
|
||||
const createPromise = createEnvelopeItems(formData);
|
||||
|
||||
registerPendingMutation(createPromise);
|
||||
|
||||
const { data } = await createPromise.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
// Set error state on files in batch upload.
|
||||
@@ -153,17 +244,84 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
envelopeItemId: item.id,
|
||||
title: item.title,
|
||||
isUploading: false,
|
||||
isReplacing: false,
|
||||
isError: false,
|
||||
})),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const onReplacePdf = async (envelopeItemId: string, file: File) => {
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: true } : f)),
|
||||
);
|
||||
|
||||
try {
|
||||
if (isEmbedded) {
|
||||
// For embedded mode, store the file data locally on the envelope item.
|
||||
// The actual replacement will happen when the embed flow submits.
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const data = new Uint8Array(arrayBuffer.slice(0));
|
||||
|
||||
// Count pages in the new PDF to remove out-of-bounds fields.
|
||||
const { PDF } = await import('@libpdf/core');
|
||||
const pdfDoc = await PDF.load(data);
|
||||
const newPageCount = pdfDoc.getPageCount();
|
||||
|
||||
// Remove fields that are on pages beyond the new PDF's page count.
|
||||
const remainingFields = envelope.fields.filter(
|
||||
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
|
||||
);
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((item) =>
|
||||
item.id === envelopeItemId ? { ...item, data } : item,
|
||||
),
|
||||
fields: remainingFields,
|
||||
});
|
||||
|
||||
editorFields.resetForm(remainingFields);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode: upload immediately via tRPC.
|
||||
const payload = {
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId,
|
||||
} satisfies TReplaceEnvelopeItemPdfPayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
const replacePromise = replaceEnvelopeItemPdf(formData);
|
||||
registerPendingMutation(replacePromise);
|
||||
|
||||
await replacePromise;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`Replace failed`,
|
||||
description: t`Something went wrong while replacing the PDF`,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((f) => (f.envelopeItemId === envelopeItemId ? { ...f, isReplacing: false } : f)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide the envelope item from the list on deletion.
|
||||
*/
|
||||
const onFileDelete = (envelopeItemId: string) => {
|
||||
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
|
||||
setLocalFiles((prev) =>
|
||||
prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId),
|
||||
);
|
||||
|
||||
const fieldsWithoutDeletedItem = envelope.fields.filter(
|
||||
(field) => field.envelopeItemId !== envelopeItemId,
|
||||
@@ -194,18 +352,60 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
debouncedUpdateEnvelopeItems(items);
|
||||
};
|
||||
|
||||
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 { triggerSave: debouncedUpdateEnvelopeItems, flush: flushUpdateEnvelopeItems } =
|
||||
useEnvelopeAutosave(
|
||||
async (files: LocalFile[]) => {
|
||||
if (isEmbedded) {
|
||||
const nextEnvelopeItems = files
|
||||
.filter((item) => item.envelopeItemId)
|
||||
.map((item, index) => {
|
||||
const originalEnvelopeItem = envelope.envelopeItems.find(
|
||||
(envelopeItem) => envelopeItem.id === item.envelopeItemId,
|
||||
);
|
||||
|
||||
return {
|
||||
id: item.envelopeItemId || '',
|
||||
title: item.title,
|
||||
order: index + 1,
|
||||
envelopeId: envelope.id,
|
||||
data: originalEnvelopeItem?.data,
|
||||
documentDataId: originalEnvelopeItem?.documentDataId || '',
|
||||
};
|
||||
});
|
||||
|
||||
setLocalEnvelope({
|
||||
envelopeItems: nextEnvelopeItems,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await updateEnvelopeItems({
|
||||
envelopeId: envelope.id,
|
||||
data: files
|
||||
.filter((item) => item.envelopeItemId)
|
||||
.map((item, index) => ({
|
||||
envelopeItemId: item.envelopeItemId || '',
|
||||
order: index + 1,
|
||||
title: item.title,
|
||||
})),
|
||||
});
|
||||
},
|
||||
isEmbedded ? 0 : 1000,
|
||||
);
|
||||
|
||||
const flushUpdateEnvelopeItemsRef = useRef(flushUpdateEnvelopeItems);
|
||||
flushUpdateEnvelopeItemsRef.current = flushUpdateEnvelopeItems;
|
||||
|
||||
// Register the flush callback with the provider so flushAutosave can await
|
||||
// pending envelope item mutations. We intentionally do NOT unregister on unmount
|
||||
// because the upload page is unmounted (replaced with a spinner) before
|
||||
// flushAutosave runs during step transitions. The hook's internal refs survive
|
||||
// unmounting, so the flush callback remains valid.
|
||||
useEffect(() => {
|
||||
registerExternalFlush('envelopeItems', async () => flushUpdateEnvelopeItemsRef.current());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onEnvelopeItemTitleChange = (envelopeItemId: string, title: string) => {
|
||||
const newLocalFilesValue = localFiles.map((uploadingFile) =>
|
||||
@@ -217,7 +417,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
};
|
||||
|
||||
const dropzoneDisabledMessage = useMemo(() => {
|
||||
if (!canItemsBeModified) {
|
||||
if (!envelopeItemPermissions.canFileBeChanged) {
|
||||
return msg`Cannot upload items after the document has been sent`;
|
||||
}
|
||||
|
||||
@@ -258,7 +458,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
description: t`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -266,6 +466,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||
<input {...getReplaceInputProps()} />
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
@@ -277,32 +478,46 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<DocumentDropzone
|
||||
onDrop={onFileDrop}
|
||||
allowMultiple
|
||||
className="pb-4 pt-6"
|
||||
disabled={dropzoneDisabledMessage !== null}
|
||||
disabledMessage={dropzoneDisabledMessage || undefined}
|
||||
disabledHeading={msg`Upload disabled`}
|
||||
maxFiles={maximumEnvelopeItemCount - localFiles.length}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
{uploadConfig?.allowUpload && (
|
||||
<DocumentDropzone
|
||||
data-testid="envelope-item-dropzone"
|
||||
onDrop={onFileDrop}
|
||||
allowMultiple
|
||||
className="pb-4 pt-6"
|
||||
disabled={dropzoneDisabledMessage !== null}
|
||||
disabledMessage={dropzoneDisabledMessage || undefined}
|
||||
disabledHeading={msg`Upload disabled`}
|
||||
maxFiles={maximumEnvelopeItemCount - localFiles.length}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Uploaded Files List */}
|
||||
<div className="mt-4">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="files">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
|
||||
<div
|
||||
data-testid="envelope-items-list"
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
className="space-y-2"
|
||||
>
|
||||
{localFiles.map((localFile, index) => (
|
||||
<Draggable
|
||||
key={localFile.id}
|
||||
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
|
||||
isDragDisabled={
|
||||
isCreatingEnvelopeItems ||
|
||||
!envelopeItemPermissions.canOrderBeChanged ||
|
||||
localFile.isReplacing ||
|
||||
!uploadConfig?.allowConfigureOrder
|
||||
}
|
||||
draggableId={localFile.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
data-testid={`envelope-item-row-${localFile.id}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={provided.draggableProps.style}
|
||||
@@ -311,18 +526,26 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{uploadConfig?.allowConfigureOrder && (
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
data-testid={`envelope-item-drag-handle-${localFile.id}`}
|
||||
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}
|
||||
disabled={
|
||||
!envelopeItemPermissions.canTitleBeChanged ||
|
||||
!uploadConfig?.allowConfigureTitle ||
|
||||
localFile.isReplacing
|
||||
}
|
||||
value={localFile.title}
|
||||
dataTestId={`envelope-item-title-input-${localFile.id}`}
|
||||
placeholder={t`Document Title`}
|
||||
onChange={(title) => {
|
||||
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
|
||||
@@ -337,15 +560,14 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
<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}
|
||||
) : 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-muted-foreground" />
|
||||
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -355,20 +577,58 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{localFile.envelopeItemId &&
|
||||
envelopeItemPermissions.canFileBeChanged &&
|
||||
uploadConfig?.allowReplace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`envelope-item-replace-button-${localFile.id}`}
|
||||
disabled={localFile.isReplacing || localFile.isUploading}
|
||||
onClick={() => {
|
||||
replacingItemIdRef.current = localFile.envelopeItemId;
|
||||
openReplaceFilePicker();
|
||||
}}
|
||||
>
|
||||
{localFile.isReplacing ? (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{localFile.envelopeItemId &&
|
||||
uploadConfig?.allowDelete &&
|
||||
(isEmbedded ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`envelope-item-remove-button-${localFile.id}`}
|
||||
onClick={() => onFileDelete(localFile.envelopeItemId!)}
|
||||
disabled={localFile.isReplacing || localFile.isUploading}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<EnvelopeItemDeleteDialog
|
||||
canItemBeDeleted={envelopeItemPermissions.canFileBeChanged}
|
||||
envelopeId={envelope.id}
|
||||
envelopeItemId={localFile.envelopeItemId}
|
||||
envelopeItemTitle={localFile.title}
|
||||
onDelete={onFileDelete}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`envelope-item-remove-button-${localFile.id}`}
|
||||
disabled={localFile.isReplacing || localFile.isUploading}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -386,13 +646,13 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
{/* Recipients Section */}
|
||||
<EnvelopeEditorRecipientForm />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button asChild>
|
||||
<Link to={`${relativePath.editorPath}?step=addFields`}>
|
||||
{editorConfig.general.allowAddFieldsStep && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => void navigateToStep('addFields')}>
|
||||
<Trans>Add Fields</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CopyPlusIcon,
|
||||
DownloadCloudIcon,
|
||||
EyeIcon,
|
||||
FileOutputIcon,
|
||||
LinkIcon,
|
||||
MousePointer,
|
||||
type LucideIcon,
|
||||
MousePointerIcon,
|
||||
SendIcon,
|
||||
SettingsIcon,
|
||||
Trash2Icon,
|
||||
Upload,
|
||||
UploadIcon,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import {
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
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 { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||
|
||||
@@ -43,52 +45,93 @@ import EnvelopeEditorHeader from './envelope-editor-header';
|
||||
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
||||
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
||||
|
||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
type EnvelopeEditorStepData = {
|
||||
id: string;
|
||||
title: MessageDescriptor;
|
||||
icon: LucideIcon;
|
||||
description: MessageDescriptor;
|
||||
};
|
||||
|
||||
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`,
|
||||
},
|
||||
];
|
||||
const UPLOAD_STEP = {
|
||||
id: 'upload',
|
||||
title: msg`Document & Recipients`,
|
||||
icon: UploadIcon,
|
||||
description: msg`Upload documents and add recipients`,
|
||||
};
|
||||
|
||||
export default function EnvelopeEditor() {
|
||||
const ADD_FIELDS_STEP = {
|
||||
id: 'addFields',
|
||||
title: msg`Add Fields`,
|
||||
icon: MousePointerIcon,
|
||||
description: msg`Place and configure form fields in the document`,
|
||||
};
|
||||
|
||||
const PREVIEW_STEP = {
|
||||
id: 'preview',
|
||||
title: msg`Preview`,
|
||||
icon: EyeIcon,
|
||||
description: msg`Preview the document before sending`,
|
||||
};
|
||||
|
||||
export const EnvelopeEditor = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
editorConfig,
|
||||
isDocument,
|
||||
isTemplate,
|
||||
isAutosaving,
|
||||
flushAutosave,
|
||||
relativePath,
|
||||
editorFields,
|
||||
navigateToStep,
|
||||
syncEnvelope,
|
||||
flushAutosave,
|
||||
resetForms,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isStepLoading, setIsStepLoading] = useState(false);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
|
||||
const {
|
||||
general: {
|
||||
minimizeLeftSidebar,
|
||||
allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep,
|
||||
allowPreviewStep,
|
||||
},
|
||||
actions: {
|
||||
allowDistributing,
|
||||
allowDirectLink,
|
||||
allowDuplication,
|
||||
allowSaveAsTemplate,
|
||||
allowDownloadPDF,
|
||||
allowDeletion,
|
||||
},
|
||||
} = editorConfig;
|
||||
|
||||
const envelopeEditorSteps = useMemo(() => {
|
||||
const steps: EnvelopeEditorStepData[] = [];
|
||||
|
||||
if (allowUploadAndRecipientStep) {
|
||||
steps.push(UPLOAD_STEP);
|
||||
}
|
||||
|
||||
if (allowAddFieldsStep) {
|
||||
steps.push(ADD_FIELDS_STEP);
|
||||
}
|
||||
|
||||
if (allowPreviewStep) {
|
||||
steps.push(PREVIEW_STEP);
|
||||
}
|
||||
|
||||
return steps.map((step, index) => ({
|
||||
...step,
|
||||
order: index + 1,
|
||||
}));
|
||||
}, [editorConfig]);
|
||||
|
||||
const searchParamsStep = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
|
||||
|
||||
// Empty URL param equals upload, otherwise use the step URL param
|
||||
@@ -103,31 +146,28 @@ export default function EnvelopeEditor() {
|
||||
}
|
||||
|
||||
return 'upload';
|
||||
});
|
||||
}, [searchParams]);
|
||||
|
||||
const navigateToStep = (step: EnvelopeEditorStep) => {
|
||||
setCurrentStep(step);
|
||||
const [pageToRender, setPageToRender] = useState<EnvelopeEditorStep | 'loading'>(
|
||||
searchParamsStep,
|
||||
);
|
||||
|
||||
void flushAutosave();
|
||||
const latestStepChangeTime = useRef(0);
|
||||
|
||||
if (!isStepLoading && isAutosaving) {
|
||||
setIsStepLoading(true);
|
||||
}
|
||||
const handleStepChange = async (step: EnvelopeEditorStep) => {
|
||||
setPageToRender('loading');
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
const currentTime = Date.now();
|
||||
latestStepChangeTime.current = currentTime;
|
||||
|
||||
await flushAutosave().then(() => {
|
||||
if (currentTime !== latestStepChangeTime.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetForms();
|
||||
setPageToRender(step);
|
||||
});
|
||||
};
|
||||
|
||||
// Watch the URL params and setStep if the step changes.
|
||||
@@ -136,79 +176,140 @@ export default function EnvelopeEditor() {
|
||||
|
||||
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
|
||||
|
||||
if (foundStep && foundStep.id !== currentStep) {
|
||||
if (foundStep && foundStep.id !== pageToRender) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
navigateToStep(foundStep.id as EnvelopeEditorStep);
|
||||
void handleStepChange(foundStep.id as EnvelopeEditorStep);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAutosaving) {
|
||||
setIsStepLoading(false);
|
||||
}
|
||||
}, [isAutosaving]);
|
||||
|
||||
const currentStepData =
|
||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||
envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
|
||||
<div className="h-screen w-screen bg-envelope-editor-background">
|
||||
<EnvelopeEditorHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4',
|
||||
{
|
||||
'w-14': minimizeLeftSidebar,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{/* Left section step selector. */}
|
||||
<div className="px-4">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||
|
||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Trans context="The step counter">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="absolute inset-y-0 left-0 bg-primary"
|
||||
style={{
|
||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
{minimizeLeftSidebar ? (
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="relative flex h-10 w-10 items-center justify-center">
|
||||
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
|
||||
{/* Track circle */}
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-muted"
|
||||
/>
|
||||
{/* Progress arc */}
|
||||
<motion.circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="text-primary"
|
||||
strokeDasharray={2 * Math.PI * 16}
|
||||
initial={false}
|
||||
animate={{
|
||||
strokeDashoffset:
|
||||
2 *
|
||||
Math.PI *
|
||||
16 *
|
||||
(1 - (currentStepData.order ?? 0) / envelopeEditorSteps.length),
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-foreground">
|
||||
<Trans context="The step counter">
|
||||
{currentStepData.order}/{envelopeEditorSteps.length}
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{envelopeEditorSteps.map((step) => {
|
||||
const Icon = step.icon;
|
||||
const isActive = currentStep === step.id;
|
||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Trans context="The step counter">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`cursor-pointer rounded-lg p-3 transition-colors ${
|
||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="absolute inset-y-0 left-0 bg-primary"
|
||||
style={{
|
||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn('space-y-3', {
|
||||
'px-4': !minimizeLeftSidebar,
|
||||
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
|
||||
})}
|
||||
>
|
||||
{envelopeEditorSteps.map((step) => {
|
||||
const Icon = step.icon;
|
||||
const isActive = searchParamsStep === step.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
data-testid={`envelope-editor-step-${step.id}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
`cursor-pointer rounded-lg text-left transition-colors ${
|
||||
isActive
|
||||
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
|
||||
}`}
|
||||
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className={`rounded border p-2 ${
|
||||
isActive
|
||||
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
|
||||
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
|
||||
/>
|
||||
</div>
|
||||
}`,
|
||||
{
|
||||
'p-3': !minimizeLeftSidebar,
|
||||
},
|
||||
)}
|
||||
onClick={() => void 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 dark:border-green-500/20 dark:bg-green-500/10'
|
||||
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<div>
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
@@ -221,158 +322,292 @@ export default function EnvelopeEditor() {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
<Separator
|
||||
className={cn('my-6', {
|
||||
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<div className="space-y-3 px-4">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Quick Actions</Trans>
|
||||
</h4>
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
|
||||
'px-2': minimizeLeftSidebar,
|
||||
})}
|
||||
>
|
||||
{!minimizeLeftSidebar && (
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Quick Actions</Trans>
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{isDocument && (
|
||||
<EnvelopeDistributeDialog
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
{editorConfig.settings && (
|
||||
<EnvelopeEditorSettingsDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<SendIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Send Document</Trans>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Settings`)}
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
{isDocument ? (
|
||||
<Trans>Document Settings</Trans>
|
||||
) : (
|
||||
<Trans>Template Settings</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
{isDocument && allowDistributing && (
|
||||
<>
|
||||
<EnvelopeDistributeDialog
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Send Envelope`)}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
<Trans>Send Document</Trans>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeRedistributeDialog
|
||||
envelope={envelope}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Resend Envelope`)}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
<Trans>Resend Document</Trans>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Save as Template
|
||||
</Button> */}
|
||||
|
||||
{isTemplate && (
|
||||
{isTemplate && allowDirectLink && (
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
directLink={envelope.directLink}
|
||||
recipients={envelope.recipients}
|
||||
onCreateSuccess={async () => await syncEnvelope()}
|
||||
onDeleteSuccess={async () => await syncEnvelope()}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Direct Link</Trans>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Direct Link`)}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
<Trans>Direct Link</Trans>
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
{allowDuplication && (
|
||||
<EnvelopeDuplicateDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeType={envelope.type}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Duplicate Envelope`)}
|
||||
>
|
||||
<CopyPlusIcon className="h-4 w-4" />
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Download PDF</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
{isDocument ? (
|
||||
<Trans>Duplicate Document</Trans>
|
||||
) : (
|
||||
<Trans>Duplicate Template</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
{allowSaveAsTemplate && isDocument && (
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={envelope.id}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Save as Template`)}
|
||||
>
|
||||
<FileOutputIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
<Trans>Save as Template</Trans>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowDownloadPDF && (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Download PDF`)}
|
||||
>
|
||||
<DownloadCloudIcon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
<Trans>Download PDF</Trans>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Check envelope ID since it can be in embedded create mode. */}
|
||||
{allowDeletion && envelope.id && (
|
||||
<EnvelopeDeleteDialog
|
||||
id={envelope.id}
|
||||
type={envelope.type}
|
||||
status={envelope.status}
|
||||
title={envelope.title}
|
||||
canManageDocument={true}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
title={t(msg`Delete Envelope`)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
{isDocument ? (
|
||||
<Trans>Delete Document</Trans>
|
||||
) : (
|
||||
<Trans>Delete Template</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
onDelete={async () => {
|
||||
await navigate(
|
||||
envelope.type === EnvelopeType.DOCUMENT
|
||||
? relativePath.documentRootPath
|
||||
: relativePath.templateRootPath,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDocument ? (
|
||||
<DocumentDeleteDialog
|
||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
status={envelope.status}
|
||||
documentTitle={envelope.title}
|
||||
canManageDocument={true}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={async () => {
|
||||
await navigate(relativePath.documentRootPath);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TemplateDeleteDialog
|
||||
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={async () => {
|
||||
await navigate(relativePath.templateRootPath);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer of left sidebar. */}
|
||||
<div className="mt-auto px-4">
|
||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||
<Link to={relativePath.basePath}>
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
{isDocument ? (
|
||||
<Trans>Return to documents</Trans>
|
||||
) : (
|
||||
<Trans>Return to templates</Trans>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{!editorConfig.embedded && (
|
||||
<div
|
||||
className={cn('mt-auto px-4', {
|
||||
'px-2': minimizeLeftSidebar,
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', {
|
||||
'flex items-center justify-center': minimizeLeftSidebar,
|
||||
})}
|
||||
asChild
|
||||
>
|
||||
<Link to={relativePath.basePath}>
|
||||
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
|
||||
|
||||
{!minimizeLeftSidebar && (
|
||||
<span className="ml-2">
|
||||
{isDocument ? (
|
||||
<Trans>Return to documents</Trans>
|
||||
) : (
|
||||
<Trans>Return to templates</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content - Changes based on current step */}
|
||||
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
|
||||
{match({ currentStep, isStepLoading })
|
||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
|
||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
|
||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
|
||||
.exhaustive()}
|
||||
</AnimateGenericFadeInOut>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{match({
|
||||
pageToRender,
|
||||
allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep,
|
||||
allowPreviewStep,
|
||||
})
|
||||
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
|
||||
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => (
|
||||
<EnvelopeEditorUploadPage />
|
||||
))
|
||||
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => (
|
||||
<EnvelopeEditorFieldsPage />
|
||||
))
|
||||
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => (
|
||||
<EnvelopeEditorPreviewPage />
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ type EnvelopeItemSelectorProps = {
|
||||
secondaryText: React.ReactNode;
|
||||
isSelected: boolean;
|
||||
buttonProps: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
actionSlot?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeItemSelector = ({
|
||||
@@ -17,11 +18,12 @@ export const EnvelopeItemSelector = ({
|
||||
secondaryText,
|
||||
isSelected,
|
||||
buttonProps,
|
||||
actionSlot,
|
||||
}: EnvelopeItemSelectorProps) => {
|
||||
return (
|
||||
<button
|
||||
title={typeof primaryText === 'string' ? primaryText : undefined}
|
||||
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
className={`group flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
isSelected
|
||||
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
|
||||
: 'border-border bg-muted/50 hover:bg-muted/70'
|
||||
@@ -39,11 +41,13 @@ export const EnvelopeItemSelector = ({
|
||||
<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 flex-shrink-0 rounded-full', {
|
||||
'bg-green-500': isSelected,
|
||||
})}
|
||||
></div>
|
||||
{actionSlot ?? (
|
||||
<div
|
||||
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
|
||||
'bg-green-500': isSelected,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -52,17 +56,24 @@ type EnvelopeRendererFileSelectorProps = {
|
||||
fields: { envelopeItemId: string }[];
|
||||
className?: string;
|
||||
secondaryOverride?: React.ReactNode;
|
||||
renderItemAction?: (item: { id: string; title: string }) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const EnvelopeRendererFileSelector = ({
|
||||
fields,
|
||||
className,
|
||||
secondaryOverride,
|
||||
renderItemAction,
|
||||
}: EnvelopeRendererFileSelectorProps) => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'scrollbar-hidden flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{envelopeItems.map((doc, i) => (
|
||||
<EnvelopeItemSelector
|
||||
key={doc.id}
|
||||
@@ -81,6 +92,7 @@ export const EnvelopeRendererFileSelector = ({
|
||||
buttonProps={{
|
||||
onClick: () => setCurrentEnvelopeItem(doc.id),
|
||||
}}
|
||||
actionSlot={renderItemAction?.(doc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+18
-31
@@ -5,7 +5,10 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
type PageRenderData,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
@@ -15,7 +18,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export default function EnvelopeGenericPageRenderer() {
|
||||
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const {
|
||||
@@ -28,19 +31,14 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
overrideSettings,
|
||||
} = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
});
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
},
|
||||
pageData,
|
||||
);
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
const { scale, pageNumber } = pageData;
|
||||
|
||||
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
||||
@@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
|
||||
return fields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
)
|
||||
.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
@@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
|
||||
fieldMeta?.readOnly,
|
||||
);
|
||||
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients, envelopeStatus]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
@@ -160,11 +157,9 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
<>
|
||||
{overrideSettings?.showRecipientTooltip &&
|
||||
pageData.imageLoadingState === 'loaded' &&
|
||||
localPageFields.map((field) => (
|
||||
<EnvelopeRecipientFieldTooltip
|
||||
key={field.id}
|
||||
@@ -176,14 +171,6 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
ref={canvasElement}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user