From d05bfa9fed5ca4a1d21910d78b9c337fdfebd376 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 7 Nov 2025 14:17:52 +1100 Subject: [PATCH] feat: add envelopes api (#2105) --- .../dialogs/document-duplicate-dialog.tsx | 51 +- .../dialogs/envelope-download-dialog.tsx | 40 +- .../dialogs/envelope-duplicate-dialog.tsx | 4 +- .../dialogs/template-create-dialog.tsx | 16 +- .../dialogs/template-use-dialog.tsx | 2 +- .../embed/authoring/configure-fields-view.tsx | 23 +- .../embed-direct-template-client-page.tsx | 12 +- ...tsx => embed-document-signing-page-v1.tsx} | 24 +- .../embed/embed-document-signing-page-v2.tsx | 232 +++ .../embed/embed-signing-context.tsx | 101 ++ .../multi-sign-document-signing-view.tsx | 4 +- .../forms/branding-preferences-form.tsx | 24 +- .../forms/editor/editor-field-text-form.tsx | 24 + .../direct-template/direct-template-page.tsx | 4 +- .../document-signing-complete-dialog.tsx | 11 +- .../document-signing-mobile-widget.tsx | 14 +- .../document-signing-page-view-v1.tsx | 7 +- .../document-signing-page-view-v2.tsx | 67 +- .../envelope-signing-provider.tsx | 26 +- .../document/document-certificate-qr-view.tsx | 56 +- .../document/document-drop-zone-wrapper.tsx | 18 +- .../general/document/document-edit-form.tsx | 7 +- .../document/document-page-view-button.tsx | 34 +- .../document/document-page-view-dropdown.tsx | 102 +- .../document/document-upload-button.tsx | 16 +- .../document/envelope-upload-button.tsx | 37 +- .../envelope-editor-fields-page-renderer.tsx | 14 +- .../envelope-editor-fields-page.tsx | 66 +- .../envelope-editor-preview-page.tsx | 320 +++- .../envelope-editor-recipient-form.tsx | 24 +- .../envelope-editor-settings-dialog.tsx | 28 +- .../envelope-editor-upload-page.tsx | 54 +- .../envelope-file-selector.tsx | 2 +- .../envelope-generic-page-renderer.tsx | 76 +- .../envelope-signing/envelope-signer-form.tsx | 9 +- .../envelope-signer-header.tsx | 11 +- .../envelope-signer-page-renderer.tsx | 88 +- .../envelope-signing-complete-dialog.tsx | 90 +- .../share-document-download-button.tsx | 49 - .../template/template-drop-zone-wrapper.tsx | 16 +- .../general/template/template-edit-form.tsx | 6 +- .../tables/documents-table-action-button.tsx | 48 +- .../documents-table-action-dropdown.tsx | 98 +- .../app/components/tables/inbox-table.tsx | 41 +- .../t.$teamUrl+/documents.$id._index.tsx | 19 +- .../t.$teamUrl+/documents.$id.edit.tsx | 3 +- .../t.$teamUrl+/templates.$id._index.tsx | 16 +- .../routes/_recipient+/d.$token+/_index.tsx | 2 +- .../_recipient+/sign.$token+/_index.tsx | 2 +- .../_recipient+/sign.$token+/complete.tsx | 32 +- apps/remix/app/routes/_share+/share.$slug.tsx | 4 +- apps/remix/app/routes/embed+/_v0+/_layout.tsx | 14 + .../app/routes/embed+/_v0+/direct.$token.tsx | 332 ++++ .../app/routes/embed+/_v0+/direct.$url.tsx | 138 -- .../app/routes/embed+/_v0+/sign.$token.tsx | 394 +++++ .../app/routes/embed+/_v0+/sign.$url.tsx | 181 --- .../routes/embed+/v1+/multisign+/_index.tsx | 1 + .../app/utils/field-signing/checkbox-field.ts | 39 +- apps/remix/package.json | 3 +- apps/remix/server/api/download/download.ts | 192 +++ .../server/api/download/download.types.ts | 29 + apps/remix/server/api/files.ts | 100 -- apps/remix/server/api/files.types.ts | 38 - apps/remix/server/api/files/files.helpers.ts | 81 + apps/remix/server/api/files/files.ts | 307 ++++ apps/remix/server/api/files/files.types.ts | 66 + apps/remix/server/router.ts | 24 +- apps/remix/server/trpc/hono-trpc-open-api.ts | 15 +- assets/field-font-alignment.pdf | Bin 0 -> 35434 bytes assets/field-meta.pdf | Bin 0 -> 65881 bytes package-lock.json | 782 +++++++--- package.json | 21 +- packages/api/package.json | 10 +- packages/api/v1/implementation.ts | 17 +- .../constants/field-alignment-pdf.ts | 498 ++++++ .../app-tests/constants/field-meta-pdf.ts | 482 ++++++ .../e2e/api/v2/envelopes-api.spec.ts | 560 +++++++ .../v2/test-unauthorized-api-access.spec.ts | 1374 +++++++++++++++++ .../e2e/envelopes/envelope-alignment.spec.ts | 293 ++++ .../include-document-certificate.spec.ts | 97 +- .../create-document-from-template.spec.ts | 31 +- packages/app-tests/package.json | 5 +- packages/auth/package.json | 2 +- packages/ee/package.json | 2 +- packages/lib/client-only/download-pdf.ts | 22 +- .../client-only/hooks/use-editor-fields.ts | 5 +- .../client-only/hooks/use-page-renderer.ts | 6 +- .../providers/envelope-editor-provider.tsx | 16 +- .../providers/envelope-render-provider.tsx | 87 +- packages/lib/constants/app.ts | 1 + packages/lib/package.json | 4 +- .../server-only/admin/admin-find-documents.ts | 8 + .../server-only/document/find-documents.ts | 8 + .../document/get-document-by-token.ts | 4 + .../get-document-with-details-by-id.ts | 3 + .../lib/server-only/document/send-document.ts | 30 +- .../server-only/envelope/create-envelope.ts | 35 +- ...et-envelope-for-direct-template-signing.ts | 2 +- .../get-envelope-for-recipient-signing.ts | 21 +- .../field/create-envelope-fields.ts | 12 +- .../lib/server-only/field/get-field-by-id.ts | 4 +- .../field/set-fields-for-document.ts | 2 +- ...nt-fields.ts => update-envelope-fields.ts} | 86 +- .../field/update-template-fields.ts | 116 -- packages/lib/server-only/pdf/normalize-pdf.ts | 15 +- ...ients.ts => create-envelope-recipients.ts} | 44 +- .../recipient/create-template-recipients.ts | 115 -- ...ipient.ts => delete-envelope-recipient.ts} | 63 +- .../recipient/delete-template-recipient.ts | 58 - ...ients.ts => update-envelope-recipients.ts} | 77 +- .../recipient/update-template-recipients.ts | 168 -- .../create-document-from-direct-template.ts | 2 + .../get-template-by-direct-link-token.ts | 4 + .../template/get-template-by-id.ts | 5 + packages/lib/types/document.ts | 5 + packages/lib/types/envelope.ts | 33 +- packages/lib/types/field-meta.ts | 2 +- packages/lib/types/field.ts | 29 + packages/lib/types/recipient.ts | 15 + packages/lib/types/template.ts | 5 + packages/lib/universal/crypto.ts | 2 + .../field-renderer/field-generic-items.ts | 20 + .../field-renderer/field-renderer.ts | 2 +- .../field-renderer/render-checkbox-field.ts | 16 +- .../universal/field-renderer/render-field.ts | 41 +- ...-field.ts => render-generic-text-field.ts} | 54 +- .../field-renderer/render-radio-field.ts | 2 +- packages/lib/universal/upload/get-file.ts | 8 +- .../lib/universal/upload/put-file.server.ts | 23 + packages/lib/universal/upload/put-file.ts | 2 +- packages/lib/utils/envelope-download.ts | 19 + packages/lib/utils/envelope-signing.ts | 4 - packages/lib/utils/fields.ts | 4 + packages/prisma/package.json | 8 +- packages/prisma/schema.prisma | 4 +- packages/prisma/seed/initial-seed.ts | 196 ++- packages/trpc/client/index.ts | 16 +- packages/trpc/package.json | 22 +- packages/trpc/react/index.tsx | 10 +- packages/trpc/server/context.ts | 4 + .../create-document-formdata.ts | 136 ++ .../create-document-formdata.types.ts | 97 ++ .../server/document-router/create-document.ts | 15 +- .../document-router/create-document.types.ts | 36 +- .../document-router/download-document-beta.ts | 96 ++ .../download-document-beta.types.ts | 32 + .../document-router/download-document.ts | 77 +- .../download-document.types.ts | 14 +- .../trpc/server/document-router/find-inbox.ts | 8 + .../trpc/server/document-router/router.ts | 4 + .../trpc/server/embedding-router/_router.ts | 3 +- .../get-multi-sign-document.types.ts | 5 + .../attachment/create-attachment.ts | 11 +- .../attachment/create-attachment.types.ts | 12 + .../attachment/delete-attachment.ts | 11 +- .../attachment/delete-attachment.types.ts | 12 + .../attachment/find-attachments.ts | 15 +- .../attachment/find-attachments.types.ts | 12 + .../attachment/update-attachment.ts | 11 +- .../attachment/update-attachment.types.ts | 12 + .../envelope-router/create-envelope-items.ts | 49 +- .../create-envelope-items.types.ts | 44 +- .../server/envelope-router/create-envelope.ts | 67 +- .../envelope-router/create-envelope.types.ts | 61 +- .../envelope-router/delete-envelope-item.ts | 2 + .../delete-envelope-item.types.ts | 12 + .../server/envelope-router/delete-envelope.ts | 24 +- .../envelope-router/delete-envelope.types.ts | 20 +- .../envelope-router/distribute-envelope.ts | 3 +- .../distribute-envelope.types.ts | 20 +- .../envelope-router/download-envelope-item.ts | 23 + .../download-envelope-item.types.ts | 31 + .../envelope-router/duplicate-envelope.ts | 4 +- .../duplicate-envelope.types.ts | 14 +- .../envelope-fields/create-envelope-fields.ts | 38 + .../create-envelope-fields.types.ts | 53 + .../envelope-fields/delete-envelope-field.ts | 118 ++ .../delete-envelope-field.types.ts | 22 + .../envelope-fields/get-envelope-field.ts | 29 + .../get-envelope-field.types.ts | 24 + .../envelope-fields/update-envelope-fields.ts | 39 + .../update-envelope-fields.types.ts | 52 + .../create-envelope-recipients.ts | 38 + .../create-envelope-recipients.types.ts | 32 + .../delete-envelope-recipient.ts | 30 + .../delete-envelope-recipient.types.ts | 24 + .../get-envelope-recipient.ts | 45 + .../get-envelope-recipient.types.ts | 24 + .../update-envelope-recipients.ts | 38 + .../update-envelope-recipients.types.ts | 32 + .../get-envelope-items-by-token.ts | 19 +- .../get-envelope-items-by-token.types.ts | 15 +- .../envelope-router/get-envelope-items.ts | 2 +- .../get-envelope-items.types.ts | 2 +- .../server/envelope-router/get-envelope.ts | 8 +- .../envelope-router/get-envelope.types.ts | 20 +- .../envelope-router/redistribute-envelope.ts | 3 +- .../redistribute-envelope.types.ts | 22 +- .../trpc/server/envelope-router/router.ts | 51 +- .../envelope-router/set-envelope-fields.ts | 2 +- .../set-envelope-fields.types.ts | 36 +- .../set-envelope-recipients.ts | 6 +- .../set-envelope-recipients.types.ts | 2 +- .../envelope-router/sign-envelope-field.ts | 43 + .../envelope-router/update-envelope-items.ts | 4 +- .../update-envelope-items.types.ts | 13 +- .../server/envelope-router/update-envelope.ts | 3 +- .../envelope-router/update-envelope.types.ts | 20 +- .../server/envelope-router/use-envelope.ts | 170 ++ .../envelope-router/use-envelope.types.ts | 121 ++ packages/trpc/server/field-router/router.ts | 69 +- packages/trpc/server/open-api.ts | 9 +- .../trpc/server/recipient-router/router.ts | 54 +- .../trpc/server/recipient-router/schema.ts | 8 +- .../trpc/server/template-router/router.ts | 30 +- .../trpc/server/template-router/schema.ts | 14 +- packages/trpc/server/trpc.ts | 58 +- packages/trpc/utils/data-transformer.ts | 17 + packages/trpc/utils/openapi-fetch-handler.ts | 202 +++ packages/trpc/utils/zod-form-data.ts | 32 + .../components/document/document-dialog.tsx | 61 - .../document/document-download-button.tsx | 69 - .../envelope-recipient-field-tooltip.tsx | 189 +++ .../pdf-viewer/pdf-viewer-konva-lazy.tsx | 3 + .../pdf-viewer/pdf-viewer-konva.tsx | 42 +- packages/ui/package.json | 2 +- packages/ui/primitives/combobox.tsx | 2 +- .../constants.ts | 6 + packages/ui/primitives/document-upload.tsx | 2 +- packages/ui/primitives/pdf-viewer.tsx | 45 +- 230 files changed, 10066 insertions(+), 2812 deletions(-) rename apps/remix/app/components/embed/{embed-document-signing-page.tsx => embed-document-signing-page-v1.tsx} (97%) create mode 100644 apps/remix/app/components/embed/embed-document-signing-page-v2.tsx create mode 100644 apps/remix/app/components/embed/embed-signing-context.tsx delete mode 100644 apps/remix/app/components/general/share-document-download-button.tsx create mode 100644 apps/remix/app/routes/embed+/_v0+/direct.$token.tsx delete mode 100644 apps/remix/app/routes/embed+/_v0+/direct.$url.tsx create mode 100644 apps/remix/app/routes/embed+/_v0+/sign.$token.tsx delete mode 100644 apps/remix/app/routes/embed+/_v0+/sign.$url.tsx create mode 100644 apps/remix/server/api/download/download.ts create mode 100644 apps/remix/server/api/download/download.types.ts delete mode 100644 apps/remix/server/api/files.ts delete mode 100644 apps/remix/server/api/files.types.ts create mode 100644 apps/remix/server/api/files/files.helpers.ts create mode 100644 apps/remix/server/api/files/files.ts create mode 100644 apps/remix/server/api/files/files.types.ts create mode 100644 assets/field-font-alignment.pdf create mode 100644 assets/field-meta.pdf create mode 100644 packages/app-tests/constants/field-alignment-pdf.ts create mode 100644 packages/app-tests/constants/field-meta-pdf.ts create mode 100644 packages/app-tests/e2e/api/v2/envelopes-api.spec.ts create mode 100644 packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts rename packages/lib/server-only/field/{update-document-fields.ts => update-envelope-fields.ts} (63%) delete mode 100644 packages/lib/server-only/field/update-template-fields.ts rename packages/lib/server-only/recipient/{create-document-recipients.ts => create-envelope-recipients.ts} (79%) delete mode 100644 packages/lib/server-only/recipient/create-template-recipients.ts rename packages/lib/server-only/recipient/{delete-document-recipient.ts => delete-envelope-recipient.ts} (72%) delete mode 100644 packages/lib/server-only/recipient/delete-template-recipient.ts rename packages/lib/server-only/recipient/{update-document-recipients.ts => update-envelope-recipients.ts} (77%) delete mode 100644 packages/lib/server-only/recipient/update-template-recipients.ts rename packages/lib/universal/field-renderer/{render-text-field.ts => render-generic-text-field.ts} (76%) create mode 100644 packages/lib/utils/envelope-download.ts create mode 100644 packages/trpc/server/document-router/create-document-formdata.ts create mode 100644 packages/trpc/server/document-router/create-document-formdata.types.ts create mode 100644 packages/trpc/server/document-router/download-document-beta.ts create mode 100644 packages/trpc/server/document-router/download-document-beta.types.ts create mode 100644 packages/trpc/server/envelope-router/download-envelope-item.ts create mode 100644 packages/trpc/server/envelope-router/download-envelope-item.types.ts create mode 100644 packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.ts create mode 100644 packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts create mode 100644 packages/trpc/server/envelope-router/envelope-fields/delete-envelope-field.ts create mode 100644 packages/trpc/server/envelope-router/envelope-fields/delete-envelope-field.types.ts create mode 100644 packages/trpc/server/envelope-router/envelope-fields/get-envelope-field.ts create mode 100644 packages/trpc/server/envelope-router/envelope-fields/get-envelope-field.types.ts create mode 100644 packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.ts create mode 100644 packages/trpc/server/envelope-router/envelope-fields/update-envelope-fields.types.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/delete-envelope-recipient.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/delete-envelope-recipient.types.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.types.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts create mode 100644 packages/trpc/server/envelope-router/use-envelope.ts create mode 100644 packages/trpc/server/envelope-router/use-envelope.types.ts create mode 100644 packages/trpc/utils/data-transformer.ts create mode 100644 packages/trpc/utils/openapi-fetch-handler.ts create mode 100644 packages/trpc/utils/zod-form-data.ts delete mode 100644 packages/ui/components/document/document-dialog.tsx delete mode 100644 packages/ui/components/document/document-download-button.tsx create mode 100644 packages/ui/components/document/envelope-recipient-field-tooltip.tsx diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 81ec5bdf4..21e788ffa 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -19,13 +19,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useCurrentTeam } from '~/providers/team'; type DocumentDuplicateDialogProps = { - id: number; + id: string; + token?: string; open: boolean; onOpenChange: (_open: boolean) => void; }; export const DocumentDuplicateDialog = ({ id, + token, open, onOpenChange, }: DocumentDuplicateDialogProps) => { @@ -36,27 +38,23 @@ export const DocumentDuplicateDialog = ({ const team = useCurrentTeam(); - const { data: document, isLoading } = trpcReact.document.get.useQuery( - { - documentId: id, - }, - { - queryHash: `document-duplicate-dialog-${id}`, - enabled: open === true, - }, - ); + const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = + trpcReact.envelope.item.getManyByToken.useQuery( + { + envelopeId: id, + access: token ? { type: 'recipient', token } : { type: 'user' }, + }, + { + enabled: open, + }, + ); - const documentData = document?.documentData - ? { - ...document.documentData, - data: document.documentData.initialData, - } - : undefined; + const envelopeItems = envelopeItemsPayload?.data || []; const documentsPath = formatDocumentsPath(team.url); - const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = - trpcReact.document.duplicate.useMutation({ + const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = + trpcReact.envelope.duplicate.useMutation({ onSuccess: async ({ id }) => { toast({ title: _(msg`Document Duplicated`), @@ -71,7 +69,7 @@ export const DocumentDuplicateDialog = ({ const onDuplicate = async () => { try { - await duplicateDocument({ documentId: id }); + await duplicateEnvelope({ envelopeId: id }); } catch { toast({ title: _(msg`Something went wrong`), @@ -83,14 +81,14 @@ export const DocumentDuplicateDialog = ({ }; return ( - !isLoading && onOpenChange(value)}> + !isDuplicating && onOpenChange(value)}> Duplicate - {!documentData || isLoading ? ( + {isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (

Loading Document... @@ -98,7 +96,12 @@ export const DocumentDuplicateDialog = ({

) : (
- +
)} @@ -115,8 +118,8 @@ export const DocumentDuplicateDialog = ({ +
+ {/* Footer of left sidebar. */} + {!isEmbed && ( +
+ +
+ )}
-
+
{/* Horizontal envelope item selector */} {envelopeItems.length > 1 && ( @@ -202,11 +226,11 @@ export const DocumentSigningPageViewV2 = () => { )} {/* Document View */} -
+
{currentEnvelopeItem ? ( ) : ( @@ -218,9 +242,20 @@ export const DocumentSigningPageViewV2 = () => { )} {/* Mobile widget - Additional padding to allow users to scroll */} -
+
+ + {!hidePoweredBy && ( + + Powered by + + + )}
diff --git a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx index aaecd3ee9..ddaf3c355 100644 --- a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx @@ -13,6 +13,7 @@ import { prop, sortBy } from 'remeda'; import { isBase64Image } from '@documenso/lib/constants/signatures'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired, isRequiredField, @@ -51,7 +52,11 @@ export type EnvelopeSigningContextValue = { setSelectedAssistantRecipientId: (_value: number | null) => void; selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null; - signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise; + signField: ( + _fieldId: number, + _value: TSignEnvelopeFieldValue, + authOptions?: TRecipientActionAuth, + ) => Promise>; }; const EnvelopeSigningContext = createContext(null); @@ -284,19 +289,26 @@ export const EnvelopeSigningProvider = ({ : null; }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); - const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => { + const signField = async ( + fieldId: number, + fieldValue: TSignEnvelopeFieldValue, + authOptions?: TRecipientActionAuth, + ) => { // Set the field locally for direct templates. if (isDirectTemplate) { - handleDirectTemplateFieldInsertion(fieldId, fieldValue); - return; + const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue); + + return signedField; } - await signEnvelopeField({ + const { signedField } = await signEnvelopeField({ token: envelopeData.recipient.token, fieldId, fieldValue, - authOptions: undefined, + authOptions, }); + + return signedField; }; const handleDirectTemplateFieldInsertion = ( @@ -354,6 +366,8 @@ export const EnvelopeSigningProvider = ({ fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)), }, })); + + return updatedField; }; return ( diff --git a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx index b4360d6bb..d5ff98f75 100644 --- a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx +++ b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { Trans } from '@lingui/react/macro'; -import type { DocumentData, EnvelopeItem } from '@prisma/client'; +import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client'; +import { DownloadIcon } from 'lucide-react'; import { DateTime } from 'luxon'; import { @@ -22,9 +23,10 @@ import { } from '@documenso/ui/primitives/dialog'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; +import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; + import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector'; import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer'; -import { ShareDocumentDownloadButton } from '../share-document-download-button'; export type DocumentCertificateQRViewProps = { documentId: number; @@ -34,6 +36,7 @@ export type DocumentCertificateQRViewProps = { documentTeamUrl: string; recipientCount?: number; completedDate?: Date; + token: string; }; export const DocumentCertificateQRView = ({ @@ -44,6 +47,7 @@ export const DocumentCertificateQRView = ({ documentTeamUrl, recipientCount = 0, completedDate, + token, }: DocumentCertificateQRViewProps) => { const { data: documentViaUser } = trpc.document.get.useQuery({ documentId, @@ -96,11 +100,12 @@ export const DocumentCertificateQRView = ({ )} {internalVersion === 2 ? ( - + ) : ( @@ -119,14 +124,27 @@ export const DocumentCertificateQRView = ({
- + + Download + + } />
- +
)} @@ -138,14 +156,16 @@ type DocumentCertificateQrV2Props = { title: string; recipientCount: number; formattedDate: string; + token: string; }; const DocumentCertificateQrV2 = ({ title, recipientCount, formattedDate, + token, }: DocumentCertificateQrV2Props) => { - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender(); return (
@@ -163,18 +183,24 @@ const DocumentCertificateQrV2 = ({
- {currentEnvelopeItem && ( - - )} + + + Download + + } + />
- +
); diff --git a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx index 01cee5ad3..2dbe7e59c 100644 --- a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx @@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; +import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon try { setIsLoading(true); - const response = await putPdfFile(file); - - const { legacyDocumentId: id } = await createDocument({ + const payload = { title: file.name, - documentDataId: response.id, - timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field. + timezone: userTimezone, folderId: folderId ?? undefined, - }); + } satisfies TCreateDocumentPayloadSchema; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + formData.append('file', file); + + const { envelopeId: id } = await createDocument(formData); void refreshLimits(); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 0b731fa35..e31224f70 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -441,9 +441,10 @@ export const DocumentEditForm = ({ > setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx index 361109580..e3e7e0afc 100644 --- a/apps/remix/app/components/general/document/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -1,18 +1,14 @@ -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; import { Link } from 'react-router'; import { match } from 'ts-pattern'; -import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { Button } from '@documenso/ui/primitives/button'; -import { useToast } from '@documenso/ui/primitives/use-toast'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; @@ -23,9 +19,6 @@ export type DocumentPageViewButtonProps = { export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => { const { user } = useSession(); - const { toast } = useToast(); - const { _ } = useLingui(); - const recipient = envelope.recipients.find((recipient) => recipient.email === user.email); const isRecipient = !!recipient; @@ -37,25 +30,6 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps const documentsPath = formatDocumentsPath(envelope.team.url); const formatPath = `${documentsPath}/${envelope.id}/edit`; - const onDownloadClick = async () => { - try { - // Todo; Envelopes - Support multiple items - const envelopeItem = envelope.envelopeItems[0]; - - if (!envelopeItem.documentData) { - throw new Error('No document available'); - } - - await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`An error occurred while downloading your document.`), - variant: 'destructive', - }); - } - }; - return match({ isRecipient, isPending, @@ -95,7 +69,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps )) - .with({ isComplete: true, internalVersion: 2 }, () => ( + .with({ isComplete: true }, () => ( )) - .with({ isComplete: true }, () => ( - - )) .otherwise(() => null); }; diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx index c5b6c9371..282740369 100644 --- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; -import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus } from '@prisma/client'; @@ -16,13 +15,11 @@ import { } from 'lucide-react'; import { Link, useNavigate } from 'react-router'; -import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { useSession } from '@documenso/lib/client-only/providers/session'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DropdownMenu, @@ -67,64 +64,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP const documentsPath = formatDocumentsPath(team.url); - const onDownloadClick = async () => { - try { - const documentWithData = await trpcClient.document.get.query( - { - documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), - }, - { - context: { - teamId: team?.id?.toString(), - }, - }, - ); - - const documentData = documentWithData?.documentData; - - if (!documentData) { - return; - } - - await downloadPDF({ documentData, fileName: envelope.title }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`An error occurred while downloading your document.`), - variant: 'destructive', - }); - } - }; - - const onDownloadOriginalClick = async () => { - try { - const documentWithData = await trpcClient.document.get.query( - { - documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), - }, - { - context: { - teamId: team?.id?.toString(), - }, - }, - ); - - const documentData = documentWithData?.documentData; - - if (!documentData) { - return; - } - - await downloadPDF({ documentData, fileName: envelope.title, version: 'original' }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _(msg`An error occurred while downloading your document.`), - variant: 'destructive', - }); - } - }; - const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED'); return ( @@ -147,36 +86,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP )} - {envelope.internalVersion === 2 ? ( - e.preventDefault()}> -
- - Download -
- - } - /> - ) : ( - <> - {isComplete && ( - + e.preventDefault()}> +
Download - - )} - - - - Download Original +
- - )} + } + /> @@ -250,7 +173,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP {isDuplicateDialogOpen && ( diff --git a/apps/remix/app/components/general/document/document-upload-button.tsx b/apps/remix/app/components/general/document/document-upload-button.tsx index 7e092363e..ea95bae23 100644 --- a/apps/remix/app/components/general/document/document-upload-button.tsx +++ b/apps/remix/app/components/general/document/document-upload-button.tsx @@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; +import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types'; import { cn } from '@documenso/ui/lib/utils'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { @@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) = try { setIsLoading(true); - const response = await putPdfFile(file); - - const { legacyDocumentId: id } = await createDocument({ + const payload = { title: file.name, - documentDataId: response.id, timezone: userTimezone, folderId: folderId ?? undefined, - }); + } satisfies TCreateDocumentPayloadSchema; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + formData.append('file', file); + + const { envelopeId: id } = await createDocument(formData); void refreshLimits(); diff --git a/apps/remix/app/components/general/document/envelope-upload-button.tsx b/apps/remix/app/components/general/document/envelope-upload-button.tsx index 645c3845f..218235706 100644 --- a/apps/remix/app/components/general/document/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/document/envelope-upload-button.tsx @@ -14,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; +import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types'; import { cn } from '@documenso/ui/lib/utils'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { @@ -78,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo try { setIsLoading(true); - const result = await Promise.all( - files.map(async (file) => { - try { - const response = await putPdfFile(file); - - return { - title: file.name, - documentDataId: response.id, - }; - } catch (err) { - console.error(err); - throw new Error('Failed to upload document'); - } - }), - ); - - const envelopeItemsToCreate = result.filter( - (item): item is { title: string; documentDataId: string } => item !== undefined, - ); - - const { id } = await createEnvelope({ + const payload = { folderId, type, title: files[0].name, - items: envelopeItemsToCreate, meta: { timezone: userTimezone, }, - }).catch((error) => { + } satisfies TCreateEnvelopePayload; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + + for (const file of files) { + formData.append('files', file); + } + + const { id } = await createEnvelope(formData).catch((error) => { console.error(error); throw error; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index c75fb52a5..5c26e5a5d 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop'; export default function EnvelopeEditorFieldsPageRenderer() { const { t, i18n } = useLingui(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const interactiveTransformer = useRef(null); @@ -103,7 +103,6 @@ export default function EnvelopeEditorFieldsPageRenderer() { fieldUpdates.height = fieldPageHeight; } - // Todo: envelopes Use id editorFields.updateFieldByFormId(fieldFormId, fieldUpdates); // Select the field if it is not already selected. @@ -114,7 +113,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { pageLayer.current?.batchDraw(); }; - const renderFieldOnLayer = (field: TLocalField) => { + const unsafeRenderFieldOnLayer = (field: TLocalField) => { if (!pageLayer.current) { return; } @@ -160,6 +159,15 @@ export default function EnvelopeEditorFieldsPageRenderer() { fieldGroup.on('dragend', handleResizeOrMove); }; + const renderFieldOnLayer = (field: TLocalField) => { + try { + unsafeRenderFieldOnLayer(field); + } catch (err) { + console.error(err); + setRenderError(true); + } + }; + /** * Initialize the Konva page canvas and all fields and interactions. */ diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 4bd0915da..ceb8e072a 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -27,7 +27,8 @@ import type { import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -112,9 +113,34 @@ export const EnvelopeEditorFieldsPage = () => { {/* Document View */} -
+
+ {envelope.recipients.length === 0 && ( + +
+ + Missing Recipients + + + You need at least one recipient to add fields + +
+ + +
+ )} + {currentEnvelopeItem !== null ? ( - + ) : (
@@ -130,7 +156,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */} - {currentEnvelopeItem && ( + {currentEnvelopeItem && envelope.recipients.length > 0 && (
{/* Recipient selector section. */}
@@ -138,29 +164,15 @@ export const EnvelopeEditorFieldsPage = () => { Selected Recipient - {envelope.recipients.length === 0 ? ( - - - You need at least one recipient to add fields - - -

- Click here to add a recipient -

- -
-
- ) : ( - - editorFields.setSelectedRecipient(recipient.id) - } - recipients={envelope.recipients} - className="w-full" - align="end" - /> - )} + + editorFields.setSelectedRecipient(recipient.id) + } + recipients={envelope.recipients} + className="w-full" + align="end" + /> {editorFields.selectedRecipient && !canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && ( diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index e57740cd1..1232573ac 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -1,10 +1,20 @@ -import { lazy, useEffect, useState } from 'react'; +import { lazy, useEffect, useMemo, useState } from 'react'; +import { faker } from '@faker-js/faker/locale/en'; import { Trans } from '@lingui/react/macro'; -import { ConstructionIcon, FileTextIcon } from 'lucide-react'; +import { FieldType } from '@prisma/client'; +import { FileTextIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { + EnvelopeRenderProvider, + useCurrentEnvelopeRender, +} from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; +import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; +import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; @@ -15,15 +25,169 @@ import { EnvelopeRendererFileSelector } from './envelope-file-selector'; const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer')); +// Todo: Envelopes - Dynamically import faker export const EnvelopeEditorPreviewPage = () => { const { envelope, editorFields } = useCurrentEnvelopeEditor(); - const { currentEnvelopeItem } = useCurrentEnvelopeRender(); + const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender(); const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>( 'recipient', ); + const fieldsWithPlaceholders = useMemo(() => { + return fields.map((field) => { + const fieldMeta = ZFieldAndMetaSchema.parse(field); + + const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + faker.seed(recipient.id); + + const recipientName = recipient.name || faker.person.fullName(); + const recipientEmail = recipient.email || faker.internet.email(); + + faker.seed(recipient.id + field.id); + + return { + ...field, + inserted: true, + ...match(fieldMeta) + .with({ type: FieldType.TEXT }, ({ fieldMeta }) => { + let text = fieldMeta?.text || faker.lorem.words(5); + + if (fieldMeta?.characterLimit) { + text = text.slice(0, fieldMeta?.characterLimit); + } + + return { + customText: text, + }; + }) + .with({ type: FieldType.NUMBER }, ({ fieldMeta }) => { + let number = fieldMeta?.value ?? ''; + + if (number === '') { + number = faker.number + .int({ + min: fieldMeta?.minValue ?? 0, + max: fieldMeta?.maxValue ?? 1000, + }) + .toString(); + } + + return { + customText: number, + }; + }) + .with({ type: FieldType.DATE }, () => { + const date = extractFieldInsertionValues({ + fieldValue: { + type: FieldType.DATE, + value: true, + }, + field, + documentMeta: envelope.documentMeta, + }); + + return { + customText: date.customText, + }; + }) + .with({ type: FieldType.EMAIL }, () => { + return { + customText: recipientEmail, + }; + }) + .with({ type: FieldType.NAME }, () => { + return { + customText: recipientName, + }; + }) + .with({ type: FieldType.INITIALS }, () => { + return { + customText: extractInitials(recipientName), + }; + }) + .with({ type: FieldType.RADIO }, ({ fieldMeta }) => { + const values = fieldMeta?.values ?? []; + + if (values.length === 0) { + return ''; + } + + let customText = ''; + + const preselectedValue = values.findIndex((value) => value.checked); + + if (preselectedValue !== -1) { + customText = preselectedValue.toString(); + } else { + const randomIndex = faker.number.int({ min: 0, max: values.length - 1 }); + customText = randomIndex.toString(); + } + + return { + customText, + }; + }) + .with({ type: FieldType.CHECKBOX }, ({ fieldMeta }) => { + let checkedValues: number[] = []; + + const values = fieldMeta?.values ?? []; + + values.forEach((value, index) => { + if (value.checked) { + checkedValues.push(index); + } + }); + + if (checkedValues.length === 0 && values.length > 0) { + const numberOfValues = fieldMeta?.validationLength || 1; + + checkedValues = Array.from({ length: numberOfValues }, (_, index) => index); + } + + return { + customText: toCheckboxCustomText(checkedValues), + }; + }) + .with({ type: FieldType.DROPDOWN }, ({ fieldMeta }) => { + const values = fieldMeta?.values ?? []; + + let customText = fieldMeta?.defaultValue || ''; + + if (!customText && values.length > 0) { + const randomIndex = faker.number.int({ min: 0, max: values.length - 1 }); + customText = values[randomIndex].value; + } + + return { + customText, + }; + }) + .with({ type: FieldType.SIGNATURE }, () => { + return { + customText: '', + signature: { + signatureImageAsBase64: '', + typedSignature: recipientName, + }, + }; + }) + .with({ type: FieldType.FREE_SIGNATURE }, () => { + return { + customText: '', + }; + }) + .exhaustive(), + }; + }); + }, [fields, envelope, envelope.recipients, envelope.documentMeta]); + /** * Set the selected recipient to the first recipient in the envelope. */ @@ -31,40 +195,38 @@ export const EnvelopeEditorPreviewPage = () => { editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null); }, []); + // Override the parent renderer provider so we can inject custom fields. return ( -
-
- {/* Horizontal envelope item selector */} - + +
+
+ {/* Horizontal envelope item selector */} + - {/* Document View */} -
- - - Preview Mode - - - Preview what the signed document will look like with placeholder data - - + {/* Document View */} +
+ + + Preview Mode + + + Preview what the signed document will look like with placeholder data + + - {/* Coming soon section */} -
-
- -

- Coming soon -

-

- This feature is coming soon -

-
-
- - {/* Todo: Envelopes - Remove div after preview mode is implemented */} -
{currentEnvelopeItem !== null ? ( - + ) : (
@@ -78,27 +240,28 @@ export const EnvelopeEditorPreviewPage = () => { )}
-
- {/* Right Section - Form Fields Panel */} - {currentEnvelopeItem && false && ( -
- {/* Add fields section. */} -
- {/*

+ {/* Right Section - Form Fields Panel */} + {currentEnvelopeItem && false && ( +
+ {/* Add fields section. */} +
+ {/*

Preivew Mode

*/} - - - Preview Mode - - - Preview what the signed document will look like with placeholder data - - + + + Preview Mode + + + + Preview what the signed document will look like with placeholder data + + + - {/* + {/* {
Preview what a recipient will see
Preview the signed document
*/} -
+

- {false && ( - - {selectedPreviewMode === 'recipient' && ( - <> - + {false && ( + + {selectedPreviewMode === 'recipient' && ( + <> + - {/* Recipient selector section. */} -
-

- Selected Recipient -

+ {/* Recipient selector section. */} +
+

+ Selected Recipient +

- - editorFields.setSelectedRecipient(recipient.id) - } - recipients={envelope.recipients} - className="w-full" - align="end" - /> -
- - )} - - )} -
- )} -
+ + editorFields.setSelectedRecipient(recipient.id) + } + recipients={envelope.recipients} + className="w-full" + align="end" + /> +
+ + )} + + )} +
+ )} +
+ ); }; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index ef8a1288c..375524e68 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -482,30 +482,46 @@ export const EnvelopeEditorRecipientForm = () => { const { data } = validatedFormValues; + // Weird edge case where the whole envelope is created via API + // with no signing order. If they come to this page it will show an error + // since they aren't equal and the recipient is no longer editable. + const envelopeRecipients = data.signers.map((recipient) => { + if (!canRecipientBeModified(recipient.id)) { + return { + ...recipient, + signingOrder: recipient.signingOrder, + }; + } + return recipient; + }); + const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder; const hasAllowDictateNextSignerChanged = envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner; const hasSignersChanged = - data.signers.length !== recipients.length || - data.signers.some((signer) => { + envelopeRecipients.length !== recipients.length || + envelopeRecipients.some((signer) => { const recipient = recipients.find((recipient) => recipient.id === signer.id); if (!recipient) { return true; } + const signerActionAuth = signer.actionAuth; + const recipientActionAuth = recipient.authOptions?.actionAuth || []; + return ( signer.email !== recipient.email || signer.name !== recipient.name || signer.role !== recipient.role || signer.signingOrder !== recipient.signingOrder || - !isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth) + !isDeepEqual(signerActionAuth, recipientActionAuth) ); }); if (hasSignersChanged) { - setRecipientsDebounced(validatedFormValues.data.signers); + setRecipientsDebounced(envelopeRecipients); } if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) { diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx index 0c505d55c..6593609f5 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx @@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({ const { t, i18n } = useLingui(); const { toast } = useToast(); - const { envelope } = useCurrentEnvelopeEditor(); + const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor(); const team = useCurrentTeam(); const organisation = useCurrentOrganisation(); @@ -186,14 +186,12 @@ export const EnvelopeEditorSettingsDialog = ({ documentAuth: envelope.authOptions, }); - const form = useForm({ - resolver: zodResolver(ZAddSettingsFormSchema), - defaultValues: { - externalId: envelope.externalId || '', // Todo: String or undefined? + const createDefaultValues = () => { + return { + externalId: envelope.externalId || '', visibility: envelope.visibility || '', globalAccessAuth: documentAuthOption?.globalAccessAuth || [], globalActionAuth: documentAuthOption?.globalActionAuth || [], - meta: { subject: envelope.documentMeta.subject ?? '', message: envelope.documentMeta.message ?? '', @@ -210,10 +208,13 @@ export const EnvelopeEditorSettingsDialog = ({ emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings), signatureTypes: extractTeamSignatureSettings(envelope.documentMeta), }, - }, - }); + }; + }; - const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation(); + const form = useForm({ + resolver: zodResolver(ZAddSettingsFormSchema), + defaultValues: createDefaultValues(), + }); const envelopeHasBeenSent = envelope.type === EnvelopeType.DOCUMENT && @@ -229,7 +230,6 @@ export const EnvelopeEditorSettingsDialog = ({ const emails = emailData?.data || []; - // Todo: Envelopes this doesn't make sense (look at previous) const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility); const onFormSubmit = async (data: TAddSettingsFormSchema) => { @@ -240,9 +240,7 @@ export const EnvelopeEditorSettingsDialog = ({ .safeParse(data.globalAccessAuth); try { - await updateEnvelope({ - envelopeId: envelope.id, - envelopeType: envelope.type, + await updateEnvelopeAsync({ data: { externalId: data.externalId || null, visibility: data.visibility, @@ -297,7 +295,7 @@ export const EnvelopeEditorSettingsDialog = ({ ]); useEffect(() => { - form.reset(); + form.reset(createDefaultValues()); setActiveTab('general'); }, [open, form]); @@ -323,7 +321,7 @@ export const EnvelopeEditorSettingsDialog = ({ {/* Sidebar. */} -
+
Document Settings diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index fa19bb6a1..c13ca0d66 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -18,9 +18,9 @@ import { import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; 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 type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types'; import { Button } from '@documenso/ui/primitives/button'; import { Card, @@ -67,8 +67,8 @@ export const EnvelopeEditorUploadPage = () => { const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } = trpc.envelope.item.createMany.useMutation({ - onSuccess: (data) => { - const createdEnvelopes = data.createdEnvelopeItems.filter( + onSuccess: ({ data }) => { + const createdEnvelopes = data.filter( (item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id), ); @@ -79,10 +79,10 @@ export const EnvelopeEditorUploadPage = () => { }); const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({ - onSuccess: (data) => { + onSuccess: ({ data }) => { setLocalEnvelope({ envelopeItems: envelope.envelopeItems.map((originalItem) => { - const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id); + const updatedItem = data.find((item) => item.id === originalItem.id); if (updatedItem) { return { @@ -114,36 +114,19 @@ export const EnvelopeEditorUploadPage = () => { 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({ + const payload = { envelopeId: envelope.id, - items: envelopeItemsToCreate, - }).catch((error) => { + } satisfies TCreateEnvelopeItemsPayload; + + const formData = new FormData(); + + formData.append('payload', JSON.stringify(payload)); + + for (const file of files) { + formData.append('files', file); + } + + const { data } = await createEnvelopeItems(formData).catch((error) => { console.error(error); // Set error state on files in batch upload. @@ -165,7 +148,7 @@ export const EnvelopeEditorUploadPage = () => { ); return filteredFiles.concat( - createdEnvelopeItems.map((item) => ({ + data.map((item) => ({ id: item.id, envelopeItemId: item.id, title: item.title, @@ -203,7 +186,6 @@ export const EnvelopeEditorUploadPage = () => { debouncedUpdateEnvelopeItems(items); }; - // Todo: Envelopes - Sync into envelopes data const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => { void updateEnvelopeItems({ envelopeId: envelope.id, diff --git a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx index 1d570bed7..144a2bc41 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-file-selector.tsx @@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({ {...buttonProps} >
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index f6ae5c5d7..48f13127e 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react'; import { useLingui } from '@lingui/react/macro'; +import { type Recipient, SigningStatus } from '@prisma/client'; import type Konva from 'konva'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; @@ -8,11 +9,23 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e import type { TEnvelope } from '@documenso/lib/types/envelope'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; +import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; + +type GenericLocalField = TEnvelope['fields'][number] & { + recipient: Pick; +}; export default function EnvelopeGenericPageRenderer() { const { i18n } = useLingui(); - const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender(); + const { + currentEnvelopeItem, + fields, + recipients, + getRecipientColorKey, + setRenderError, + overrideSettings, + } = useCurrentEnvelopeRender(); const { stage, @@ -28,44 +41,73 @@ export default function EnvelopeGenericPageRenderer() { const { _className, scale } = pageContext; - const localPageFields = useMemo( - () => - fields.filter( + const localPageFields = useMemo((): GenericLocalField[] => { + return fields + .filter( (field) => field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, - ), - [fields, pageContext.pageNumber], - ); + ) + .map((field) => { + const recipient = recipients.find((recipient) => recipient.id === field.recipientId); - const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => { + if (!recipient) { + throw new Error(`Recipient not found for field ${field.id}`); + } + + return { + ...field, + recipient, + }; + }); + }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); + + const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { if (!pageLayer.current) { console.error('Layer not loaded yet'); return; } + const { recipient } = field; + + const fieldTranslations = getClientSideFieldTranslations(i18n); + + const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted; + renderField({ scale, pageLayer: pageLayer.current, field: { renderId: field.id.toString(), ...field, - customText: '', width: Number(field.width), height: Number(field.height), positionX: Number(field.positionX), positionY: Number(field.positionY), - inserted: false, + customText: isInserted ? field.customText : '', fieldMeta: field.fieldMeta, + signature: { + signatureImageAsBase64: '', + typedSignature: fieldTranslations.SIGNATURE, + }, }, - translations: getClientSideFieldTranslations(i18n), + translations: fieldTranslations, pageWidth: unscaledViewport.width, pageHeight: unscaledViewport.height, color: getRecipientColorKey(field.recipientId), editable: false, - mode: 'sign', + mode: overrideSettings?.mode ?? 'sign', }); }; + const renderFieldOnLayer = (field: GenericLocalField) => { + try { + unsafeRenderFieldOnLayer(field); + } catch (err) { + console.error(err); + setRenderError(true); + } + }; + /** * Initialize the Konva page canvas and all fields and interactions. */ @@ -113,6 +155,16 @@ export default function EnvelopeGenericPageRenderer() { className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`} > + {overrideSettings?.showRecipientTooltip && + localPageFields.map((field) => ( + + ))} + {/* The element Konva will inject it's canvas into. */}
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx index 98a9173ee..e010e5821 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx @@ -8,6 +8,8 @@ import { Label } from '@documenso/ui/primitives/label'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; + import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; export default function EnvelopeSignerForm() { @@ -25,6 +27,8 @@ export default function EnvelopeSignerForm() { setSelectedAssistantRecipientId, } = useRequiredEnvelopeSigningContext(); + const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const hasSignatureField = useMemo(() => { return recipientFields.some((field) => field.type === FieldType.SIGNATURE); }, [recipientFields]); @@ -37,7 +41,7 @@ export default function EnvelopeSignerForm() { if (recipient.role === RecipientRole.ASSISTANT) { return ( -
+
setFullName(e.target.value.trimStart())} + disabled={isNameLocked} + onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())} />
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx index b48ee0c55..bf7b2a971 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx @@ -16,6 +16,7 @@ import { import { Separator } from '@documenso/ui/primitives/separator'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; +import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogoIcon } from '../branding-logo-icon'; @@ -28,7 +29,7 @@ export const EnvelopeSignerHeader = () => { useRequiredEnvelopeSigningContext(); return ( -