mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 18:35:05 +10:00
Merge branch 'main' into feat/public-completed-document-access
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminOrganisationMemberDeleteDialogProps = {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
organisationMemberId: string;
|
||||
organisationMemberName: string;
|
||||
organisationMemberEmail: string;
|
||||
};
|
||||
|
||||
export const AdminOrganisationMemberDeleteDialog = ({
|
||||
organisationId,
|
||||
organisationName,
|
||||
organisationMemberId,
|
||||
organisationMemberName,
|
||||
organisationMemberEmail,
|
||||
}: AdminOrganisationMemberDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: deleteOrganisationMember, isPending } =
|
||||
trpc.admin.organisationMember.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Member has been removed from the organisation.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
// Refresh the page to show updated data
|
||||
await navigate(0);
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(msg`We couldn't remove this member. Please try again later.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Remove Organisation Member</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are about to remove the following user from the organisation{' '}
|
||||
<span className="font-semibold">{organisationName}</span>:
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Alert className="mt-4" variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
|
||||
secondaryText={organisationMemberEmail}
|
||||
/>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={isPending}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isPending}
|
||||
onClick={async () =>
|
||||
deleteOrganisationMember({
|
||||
organisationId,
|
||||
organisationMemberId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Remove member</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AdminTeamMemberDeleteDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
memberEmail: string;
|
||||
};
|
||||
|
||||
export const AdminTeamMemberDeleteDialog = ({
|
||||
teamId,
|
||||
teamName,
|
||||
memberId,
|
||||
memberName,
|
||||
memberEmail,
|
||||
}: AdminTeamMemberDeleteDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: deleteTeamMember, isPending } = trpc.admin.teamMember.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Member has been removed from the team.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
// Refresh the page to show updated data
|
||||
await navigate(0);
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(msg`We couldn't remove this member. Please try again later.`),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>
|
||||
<Trans>Remove Team Member</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="selection:bg-red-100">
|
||||
<Trans>This action is not reversible. Please be certain.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are about to remove the following user from the team{' '}
|
||||
<span className="font-semibold">{teamName}</span>:
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<Alert className="mt-4" variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={memberName.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold">{memberName}</span>}
|
||||
secondaryText={memberEmail}
|
||||
/>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={isPending}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isPending}
|
||||
onClick={async () => deleteTeamMember({ teamId, memberId })}
|
||||
>
|
||||
<Trans>Remove member</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,7 +116,7 @@ export function AssistantConfirmationDialog({
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<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> (
|
||||
|
||||
@@ -57,7 +57,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
|
||||
</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>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
@@ -13,8 +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';
|
||||
@@ -61,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
|
||||
@@ -116,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 || [];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -10,8 +10,10 @@ 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';
|
||||
@@ -73,8 +75,14 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
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),
|
||||
defaultValues: {
|
||||
@@ -106,7 +114,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
|
||||
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
|
||||
if (members.length === 0) {
|
||||
if (hasNoAvailableMembers) {
|
||||
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
|
||||
setInviteDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
@@ -231,7 +239,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && form.getValues('members').length === 0) {
|
||||
e.preventDefault();
|
||||
if (hasNoAvailableMembers) {
|
||||
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
|
||||
setInviteDialogOpen(true);
|
||||
}
|
||||
// Don't show toast - the disabled Next button already communicates this
|
||||
@@ -260,21 +268,32 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
<Trans>No organisation members available</Trans>
|
||||
</h3>
|
||||
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
To add members to this team, you must first add them to the
|
||||
organisation.
|
||||
</Trans>
|
||||
{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>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
{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
|
||||
@@ -310,30 +329,32 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
<Trans>Select members to add to this team</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
|
||||
@@ -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,7 +53,7 @@ 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;
|
||||
|
||||
@@ -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,11 +4,11 @@ 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';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
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';
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
@@ -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;
|
||||
@@ -202,19 +201,32 @@ export function TemplateUseDialog({
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const toastPayload: Toast = {
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
'DOCUMENT_SEND_FAILED',
|
||||
() => msg`The document was created but could not be sent to recipients.`,
|
||||
)
|
||||
.with(
|
||||
AppErrorCode.INVALID_BODY,
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
() =>
|
||||
msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
|
||||
)
|
||||
.with(
|
||||
AppErrorCode.NOT_FOUND,
|
||||
() => msg`The template or one of its recipients could not be found.`,
|
||||
)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => msg`You have reached your document limit for this plan.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while creating document from template.`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while creating document from template.`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
};
|
||||
|
||||
if (error.code === 'DOCUMENT_SEND_FAILED') {
|
||||
toastPayload.description = _(
|
||||
msg`The document was created but could not be sent to recipients.`,
|
||||
);
|
||||
}
|
||||
|
||||
toast(toastPayload);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user