Files
documenso/apps/remix/app/components/general/envelope-editor/envelope-editor-page-upload.tsx
David Nguyen 7f09ba72f4 feat: add envelopes (#2025)
This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
2025-10-14 21:56:36 +11:00

349 lines
12 KiB
TypeScript

import { useMemo, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { Link } from 'react-router';
import {
useCurrentEnvelopeEditor,
useDebounceFunction,
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
type LocalFile = {
id: string;
title: string;
envelopeItemId: string | null;
isUploading: boolean;
isError: boolean;
};
export const EnvelopeEditorPageUpload = () => {
const team = useCurrentTeam();
const { t } = useLingui();
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems
.sort((a, b) => a.order - b.order)
.map((item) => ({
id: item.id,
title: item.title,
envelopeItemId: item.id,
isUploading: false,
isError: false,
})),
);
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
trpc.envelope.item.createMany.useMutation({
onSuccess: (data) => {
const createdEnvelopes = data.createdEnvelopeItems.filter(
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
);
setLocalEnvelope({
envelopeItems: [...envelope.envelopeItems, ...createdEnvelopes],
});
},
});
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
onSuccess: (data) => {
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((originalItem) => {
const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
if (updatedItem) {
return {
...originalItem,
...updatedItem,
};
}
return originalItem;
}),
});
},
});
const canItemsBeModified = useMemo(
() => canEnvelopeItemsBeModified(envelope, envelope.recipients),
[envelope, envelope.recipients],
);
const onFileDrop = async (files: File[]) => {
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({
id: nanoid(),
envelopeItemId: null,
title: file.name,
file,
isUploading: true,
isError: false,
}));
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
const result = await Promise.all(
files.map(async (file, index) => {
try {
const response = await putPdfFile(file);
// Mark as uploaded (remove from uploading state)
return {
title: file.name,
documentDataId: response.id,
};
} catch (_error) {
setLocalFiles((prev) =>
prev.map((uploadingFile) =>
uploadingFile.id === newUploadingFiles[index].id
? { ...uploadingFile, isError: true, isUploading: false }
: uploadingFile,
),
);
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { createdEnvelopeItems } = await createEnvelopeItems({
envelopeId: envelope.id,
items: envelopeItemsToCreate,
}).catch((error) => {
console.error(error);
// Set error state on files in batch upload.
setLocalFiles((prev) =>
prev.map((uploadingFile) =>
uploadingFile.id === newUploadingFiles.find((file) => file.id === uploadingFile.id)?.id
? { ...uploadingFile, isError: true, isUploading: false }
: uploadingFile,
),
);
throw error;
});
setLocalFiles((prev) => {
const filteredFiles = prev.filter(
(uploadingFile) =>
uploadingFile.id !== newUploadingFiles.find((file) => file.id === uploadingFile.id)?.id,
);
return filteredFiles.concat(
createdEnvelopeItems.map((item) => ({
id: item.id,
envelopeItemId: item.id,
title: item.title,
isUploading: false,
isError: false,
})),
);
});
};
/**
* Hide the envelope item from the list on deletion.
*/
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
});
};
/**
* Handle drag end for reordering files.
*/
const onDragEnd = (result: DropResult) => {
if (!result.destination) {
return;
}
const items = Array.from(localFiles);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setLocalFiles(items);
debouncedUpdateEnvelopeItems(items);
};
// Todo: Envelopes - Sync into envelopes data
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
}, 1000);
const onEnvelopeItemTitleChange = (envelopeItemId: string, title: string) => {
const newLocalFilesValue = localFiles.map((uploadingFile) =>
uploadingFile.envelopeItemId === envelopeItemId ? { ...uploadingFile, title } : uploadingFile,
);
setLocalFiles(newLocalFilesValue);
debouncedUpdateEnvelopeItems(newLocalFilesValue);
};
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>Documents</CardTitle>
<CardDescription>Add and configure multiple documents</CardDescription>
</CardHeader>
<CardContent>
<DocumentDropzone
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={!canItemsBeModified}
disabledMessage={msg`Cannot upload items after the document has been sent`}
disabledHeading={msg`Upload disabled`}
/>
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
{localFiles.map((localFile, index) => (
<Draggable
key={localFile.id}
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
draggableId={localFile.id}
index={index}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
className={`flex items-center justify-between rounded-lg bg-gray-50 p-3 transition-shadow ${
snapshot.isDragging ? 'shadow-md' : ''
}`}
>
<div className="flex items-center space-x-3">
<div
{...provided.dragHandleProps}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
<div>
{localFile.envelopeItemId !== null ? (
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
value={localFile.title}
placeholder={t`Document Title`}
onChange={(title) => {
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
}}
/>
) : (
<p className="text-sm font-medium">{localFile.title}</p>
)}
<div className="text-xs text-gray-500">
{localFile.isUploading ? (
<Trans>Uploading</Trans>
) : localFile.isError ? (
<Trans>Something went wrong while uploading this file</Trans>
) : // <div className="text-xs text-gray-500">2.4 MB • 3 pages</div>
null}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-gray-500" />
</div>
)}
{localFile.isError && (
<div className="flex h-6 w-10 items-center justify-center">
<FileWarningIcon className="text-destructive h-4 w-4" />
</div>
)}
{!localFile.isUploading && localFile.envelopeItemId && (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
}
/>
)}
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</CardContent>
</Card>
{/* Recipients Section */}
<EnvelopeEditorRecipientForm />
<div className="flex justify-end">
<Button asChild>
<Link to={`/t/${team.url}/documents/${envelope.id}/edit?step=addFields`}>
<Trans>Add Fields</Trans>
</Link>
</Button>
</div>
</div>
);
};