mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
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.
349 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|