From 586b1f5cb5df9a4330a9f72b8fb6e80209005696 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 29 Sep 2026 14:37:03 +1000 Subject: [PATCH 1/5] v2.19.0 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index 4a002f905..0824b0028 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -106,5 +106,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.18.0" + "version": "2.19.0" } diff --git a/package-lock.json b/package-lock.json index 57b2baff4..e6c8b65f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.18.0", + "version": "2.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.18.0", + "version": "2.19.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -193,7 +193,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.18.0", + "version": "2.19.0", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index 72d301246..a1657f4ef 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.18.0", + "version": "2.19.0", "scripts": { "postinstall": "patch-package", "build": "turbo run build", From 5a123be46c4b8173701a3e1e94591cadf4cad602 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 29 Sep 2026 15:44:25 +1000 Subject: [PATCH 2/5] fix: embed signing completion and reload states (#3409) Send completed/rejected events when reopening an actioned v1 embed, show the completed page after signing in v2, and tidy the completed page. --- .../developers/embedding/css-variables.mdx | 33 ++++++++------ .../embed/embed-document-completed.tsx | 26 ++++++----- .../embed/embed-document-signing-page-v1.tsx | 44 ++++++++++++++++++- .../embed/embed-document-signing-page-v2.tsx | 21 ++++++--- .../app/routes/embed+/_v0+/sign.$token.tsx | 17 +++++-- 5 files changed, 107 insertions(+), 34 deletions(-) diff --git a/apps/docs/content/docs/developers/embedding/css-variables.mdx b/apps/docs/content/docs/developers/embedding/css-variables.mdx index ae7185964..c798a32ba 100644 --- a/apps/docs/content/docs/developers/embedding/css-variables.mdx +++ b/apps/docs/content/docs/developers/embedding/css-variables.mdx @@ -120,20 +120,25 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin ### Component Classes -| Class | Description | -| --------------------------------- | --------------------------------------------- | -| `.embed--Root` | Main container for the embedded experience | -| `.embed--DocumentContainer` | Container for the document and signing widget | -| `.embed--DocumentViewer` | Container for the document viewer | -| `.embed--DocumentWidget` | The signing widget container | -| `.embed--DocumentWidgetContainer` | Outer container for the signing widget | -| `.embed--DocumentWidgetHeader` | Header section of the signing widget | -| `.embed--DocumentWidgetContent` | Main content area of the signing widget | -| `.embed--DocumentWidgetForm` | Form section within the signing widget | -| `.embed--DocumentWidgetFooter` | Footer section of the signing widget | -| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn | -| `.embed--DocumentCompleted` | Completion screen after signing | -| `.field--FieldRootContainer` | Base container for document fields | +| Class | Description | +| ---------------------------------------- | --------------------------------------------- | +| `.embed--Root` | Main container for the embedded experience | +| `.embed--DocumentContainer` | Container for the document and signing widget | +| `.embed--DocumentViewer` | Container for the document viewer | +| `.embed--DocumentWidget` | The signing widget container | +| `.embed--DocumentWidgetContainer` | Outer container for the signing widget | +| `.embed--DocumentWidgetHeader` | Header section of the signing widget | +| `.embed--DocumentWidgetContent` | Main content area of the signing widget | +| `.embed--DocumentWidgetForm` | Form section within the signing widget | +| `.embed--DocumentWidgetFooter` | Footer section of the signing widget | +| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn | +| `.embed--DocumentCompleted` | Completion screen after signing | +| `.embed--DocumentCompletedCard` | Signature card on the completion screen | +| `.embed--DocumentCompletedTitle` | Title on the completion screen | +| `.embed--DocumentCompletedStatus` | Status line on the completion screen | +| `.embed--DocumentCompletedDescription` | Description text on the completion screen | +| `.embed--DocumentRejected` | Rejection screen after rejecting the document | +| `.field--FieldRootContainer` | Base container for document fields | ### Field Data Attributes diff --git a/apps/remix/app/components/embed/embed-document-completed.tsx b/apps/remix/app/components/embed/embed-document-completed.tsx index da11e59ef..a898e4ee9 100644 --- a/apps/remix/app/components/embed/embed-document-completed.tsx +++ b/apps/remix/app/components/embed/embed-document-completed.tsx @@ -2,6 +2,7 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { Trans } from '@lingui/react/macro'; import type { Signature } from '@prisma/client'; +import { CheckCircle2Icon } from 'lucide-react'; export type EmbedDocumentCompletedPageProps = { name?: string; @@ -10,12 +11,8 @@ export type EmbedDocumentCompletedPageProps = { export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => { return ( -
-

- Document Completed! -

- -
+
+
-

- - The document is now completed, please follow any instructions provided within the parent application. - +

+ Document Completed +

+ +
+ + + No further action is required + +
+ +

+ Please follow any instructions provided within the parent application.

); diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 739a5c8a6..ff583ed22 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -55,6 +55,7 @@ export type EmbedSignDocumentV1ClientPageProps = { completedFields: DocumentField[]; metadata?: DocumentMeta | null; isCompleted?: boolean; + isRejected?: boolean; hidePoweredBy?: boolean; allowWhitelabelling?: boolean; allRecipients?: RecipientWithFields[]; @@ -70,6 +71,7 @@ export const EmbedSignDocumentV1ClientPage = ({ completedFields, metadata, isCompleted, + isRejected, hidePoweredBy = false, allowWhitelabelling = false, allRecipients = [], @@ -83,7 +85,9 @@ export const EmbedSignDocumentV1ClientPage = ({ const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); - const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED); + const [hasRejectedDocument, setHasRejectedDocument] = useState( + isRejected ?? recipient.signingStatus === SigningStatus.REJECTED, + ); const [selectedSignerId, setSelectedSignerId] = useState( allRecipients.length > 0 ? allRecipients[0].id : null, ); @@ -263,6 +267,44 @@ export const EmbedSignDocumentV1ClientPage = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!window.parent) { + return; + } + + if (hasRejectedDocument) { + window.parent.postMessage( + { + action: 'document-rejected', + data: { + token, + documentId, + recipientId: recipient.id, + }, + }, + '*', + ); + + return; + } + + if (hasCompletedDocument) { + window.parent.postMessage( + { + action: 'document-completed', + data: { + token, + documentId, + recipientId: recipient.id, + }, + }, + '*', + ); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (hasFinishedInit && hasDocumentLoaded && window.parent) { window.parent.postMessage( diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx index 55e7cb6d1..8973a23df 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v2.tsx @@ -40,12 +40,18 @@ export const EmbedSignDocumentV2ClientPage = ({ const [isNameLocked, setIsNameLocked] = useState(false); const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT && !!email); + // The signing provider's envelope data isn't refreshed on revalidation. + const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); + const [hasRejectedDocument, setHasRejectedDocument] = useState(isRejected); + const onDocumentCompleted = (data: { token: string; documentId: number; envelopeId: string; recipientId: number; }) => { + setHasCompletedDocument(true); + if (window.parent) { window.parent.postMessage( { @@ -112,6 +118,8 @@ export const EmbedSignDocumentV2ClientPage = ({ recipientId: number; reason?: string; }) => { + setHasRejectedDocument(true); + if (window.parent) { window.parent.postMessage( { @@ -219,23 +227,26 @@ export const EmbedSignDocumentV2ClientPage = ({ } }, [isRejected, envelope.id, recipient.id, recipient.token]); - if (isRejected) { + if (hasRejectedDocument) { return ; } - if (isCompleted) { + if (hasCompletedDocument) { + const completedSignature = + recipient.fields.find((field) => field.signature)?.signature ?? recipientSignature ?? null; + return ( Date: Tue, 29 Sep 2026 16:13:39 +1000 Subject: [PATCH 3/5] fix: use legacy pdfjs build for older devices (#3410) --- apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx index 3fa39a232..dc70f4d3f 100644 --- a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx +++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx @@ -5,8 +5,8 @@ import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { Trans, useLingui } from '@lingui/react/macro'; import pMap from 'p-map'; -import * as pdfjsLib from 'pdfjs-dist'; -import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url'; +import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'; +import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'; import type React from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; From 573c928a0e647efc8c41e3e12909c918c2104430 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 29 Sep 2026 16:52:43 +1000 Subject: [PATCH 4/5] feat: add recipient grouping (#3319) --- .../direct-template-signing-form.tsx | 26 +- .../document-signing-complete-dialog.tsx | 6 +- .../document-signing-form.tsx | 26 +- .../document-signing-page-view-v1.tsx | 37 +- .../envelope-signing-provider.tsx | 55 +- .../envelope-editor-recipient-form.tsx | 585 ++------------ .../general/envelope-editor/recipient-row.tsx | 235 ++++++ .../envelope-editor/recipient-step-card.tsx | 261 +++++++ .../envelope-editor/recipient-step-list.tsx | 398 ++++++++++ apps/remix/app/root.tsx | 2 +- .../documents.$id.legacy_editor.tsx | 4 + .../templates.$id.legacy_editor.tsx | 4 + .../_recipient+/sign.$token+/_index.tsx | 34 +- packages/api/v1/schema.ts | 9 +- .../expired-recipient-field-signing.spec.ts | 110 +++ ...recipient-signing-order-validation.spec.ts | 71 ++ .../e2e/api/v2/tsp-recipient-grouping.spec.ts | 118 +++ .../assistant-grouped-dictation-ui.spec.ts | 116 +++ .../assistant-null-order.spec.ts | 250 ++++++ .../assistant-signing-groups.spec.ts | 266 +++++++ .../grouped-next-recipient-dictation.spec.ts | 154 ++++ .../viewer-next-recipient-dictation.spec.ts | 150 ++++ .../legacy-editor-version-guard.spec.ts | 68 ++ .../envelope-recipient-cc-order.spec.ts | 18 +- .../envelope-recipient-csc-grouping.spec.ts | 259 +++++++ .../envelope-recipient-groups.spec.ts | 137 ++++ .../envelope-recipient-id-stability.spec.ts | 124 +++ ...pe-recipient-legacy-unordered-tail.spec.ts | 81 ++ .../envelope-recipient-locked-steps.spec.ts | 168 ++++ ...ope-recipient-null-order-hydration.spec.ts | 98 +++ .../envelope-recipients.spec.ts | 61 +- .../app-tests/e2e/fixtures/envelope-editor.ts | 336 +++++++- .../legacy-unordered-recipients.spec.ts | 129 ++++ .../rejected-recipient-advancement.spec.ts | 95 +++ .../signing-group-advancement.spec.ts | 153 ++++ .../e2e/recipient/signing-groups.spec.ts | 95 +++ .../direct-template-dictation-groups.spec.ts | 166 ++++ .../hooks/use-editor-recipients.ts | 95 ++- .../providers/envelope-editor-provider.tsx | 60 +- .../document/complete-document-with-token.ts | 148 ++-- .../lib/server-only/document/send-document.ts | 8 +- .../server-only/envelope/create-envelope.ts | 34 +- .../get-envelope-for-recipient-signing.ts | 24 +- .../server-only/field/get-fields-for-token.ts | 6 +- .../field/remove-signed-field-with-token.ts | 20 +- .../field/sign-field-with-token.ts | 31 +- ...n-omitted-recipient-signing-orders.test.ts | 83 ++ ...assign-omitted-recipient-signing-orders.ts | 118 +++ .../recipient/create-envelope-recipients.ts | 31 +- .../recipient/get-is-recipient-turn.ts | 32 +- .../recipient/get-next-pending-recipient.ts | 25 +- .../recipient/get-recipients-for-assistant.ts | 7 +- .../recipient/set-document-recipients.ts | 25 +- .../recipient/set-template-recipients.ts | 35 +- .../recipient/update-envelope-recipients.ts | 19 + ...sert-compatible-recipient-grouping.test.ts | 65 ++ .../assert-compatible-recipient-grouping.ts | 59 ++ .../create-document-from-direct-template.ts | 46 +- .../template/create-document-from-template.ts | 32 +- packages/lib/types/recipient.ts | 11 + packages/lib/utils/recipient-groups.test.ts | 721 ++++++++++++++++++ packages/lib/utils/recipient-groups.ts | 486 ++++++++++++ packages/lib/utils/recipient-queries.test.ts | 90 +++ packages/lib/utils/recipient-queries.ts | 97 +++ packages/lib/utils/recipients.test.ts | 46 ++ packages/lib/utils/recipients.ts | 140 +++- .../create-embedding-document.types.ts | 3 +- .../create-embedding-template.types.ts | 4 +- .../update-embedding-document.types.ts | 3 +- .../update-embedding-template.types.ts | 4 +- .../create-envelope-recipients.types.ts | 8 +- .../update-envelope-recipients.types.ts | 8 +- .../set-envelope-recipients.types.ts | 18 +- .../envelope-router/sign-envelope-field.ts | 19 +- .../envelope-router/use-envelope.types.ts | 4 +- .../trpc/server/recipient-router/schema.ts | 10 +- 76 files changed, 6624 insertions(+), 956 deletions(-) create mode 100644 apps/remix/app/components/general/envelope-editor/recipient-row.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx create mode 100644 packages/app-tests/e2e/api/trpc/envelope/expired-recipient-field-signing.spec.ts create mode 100644 packages/app-tests/e2e/api/v2/recipient-signing-order-validation.spec.ts create mode 100644 packages/app-tests/e2e/api/v2/tsp-recipient-grouping.spec.ts create mode 100644 packages/app-tests/e2e/document-auth/assistant-grouped-dictation-ui.spec.ts create mode 100644 packages/app-tests/e2e/document-auth/assistant-null-order.spec.ts create mode 100644 packages/app-tests/e2e/document-auth/assistant-signing-groups.spec.ts create mode 100644 packages/app-tests/e2e/document-auth/grouped-next-recipient-dictation.spec.ts create mode 100644 packages/app-tests/e2e/document-auth/viewer-next-recipient-dictation.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/legacy-editor-version-guard.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-csc-grouping.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-id-stability.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-legacy-unordered-tail.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-locked-steps.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-null-order-hydration.spec.ts create mode 100644 packages/app-tests/e2e/recipient/legacy-unordered-recipients.spec.ts create mode 100644 packages/app-tests/e2e/recipient/rejected-recipient-advancement.spec.ts create mode 100644 packages/app-tests/e2e/recipient/signing-group-advancement.spec.ts create mode 100644 packages/app-tests/e2e/recipient/signing-groups.spec.ts create mode 100644 packages/app-tests/e2e/templates/direct-template-dictation-groups.spec.ts create mode 100644 packages/lib/server-only/recipient/assign-omitted-recipient-signing-orders.test.ts create mode 100644 packages/lib/server-only/recipient/assign-omitted-recipient-signing-orders.ts create mode 100644 packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.test.ts create mode 100644 packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.ts create mode 100644 packages/lib/utils/recipient-groups.test.ts create mode 100644 packages/lib/utils/recipient-groups.ts create mode 100644 packages/lib/utils/recipient-queries.test.ts create mode 100644 packages/lib/utils/recipient-queries.ts diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 179d3306a..4fafb4b50 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -11,6 +11,7 @@ import { import type { TTemplate } from '@documenso/lib/types/template'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, @@ -223,27 +224,10 @@ export const DirectTemplateSigningForm = ({ return undefined; } - const sortedRecipients = template.recipients.sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; + return getNextDictatableRecipient({ + recipients: template.recipients, + currentRecipientId: directRecipient.id, }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; }, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]); return ( @@ -435,7 +419,7 @@ export const DirectTemplateSigningForm = ({ fields={localFields} fieldsValidated={fieldsValidated} recipient={directRecipient} - allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner} + allowDictateNextSigner={Boolean(nextRecipient && template.templateMeta?.allowDictateNextSigner)} defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined} />
diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index f5508f727..b8caa4a83 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -102,8 +102,10 @@ export const DocumentSigningCompleteDialog = ({ const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner); + const form = useForm({ - resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, + resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, defaultValues: { name: defaultNextSigner?.name ?? '', email: defaultNextSigner?.email ?? '', @@ -324,7 +326,7 @@ export const DocumentSigningCompleteDialog = ({
- {allowDictateNextSigner && defaultNextSigner && ( + {canDictateNextSigner && (
Promise; isSubmitting: boolean; fieldsValidated: () => void; - nextRecipient?: RecipientWithFields; + /** + * The dictatable next recipient, decided server-side. Only their identity + * is needed — for the dictation flag and the prefilled inputs. + */ + nextRecipient?: Pick; }; export const DocumentSigningForm = ({ @@ -84,6 +88,10 @@ export const DocumentSigningForm = ({ return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); }, [fieldsRequiringValidation, recipient]); + const allowDictateNextSigner = Boolean(nextRecipient && document.documentMeta?.allowDictateNextSigner); + + const defaultNextSigner = nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined; + const localFieldsValidated = () => { setValidateUninsertedFields(true); fieldsValidated(); @@ -151,10 +159,8 @@ export const DocumentSigningForm = ({ completeDocument({ nextSigner, accessAuthOptions }) } recipient={recipient} - allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={ - nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined - } + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
@@ -223,8 +229,8 @@ export const DocumentSigningForm = ({ onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} onConfirm={handleAssistantConfirmDialogSubmit} isSubmitting={isAssistantSubmitting} - allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined} + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
) : ( @@ -291,10 +297,8 @@ export const DocumentSigningForm = ({ }) } recipient={recipient} - allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={ - nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined - } + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 4fa8077a0..9ed38fbf1 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -22,7 +22,7 @@ import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Trans } from '@lingui/react/macro'; -import type { Field } from '@prisma/client'; +import type { Field, Recipient } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useMemo, useState } from 'react'; @@ -60,6 +60,12 @@ export type DocumentSigningPageViewV1Props = { completedFields: CompletedField[]; isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; + /** + * The dictatable next recipient, computed server-side over the FULL + * recipient list — must not be re-derived from the role-scoped + * `allRecipients`. + */ + nextRecipient?: Pick; branding: DocumentSigningBranding; includeSenderDetails: boolean; }; @@ -71,6 +77,7 @@ export const DocumentSigningPageViewV1 = ({ completedFields, isRecipientsTurn, allRecipients = [], + nextRecipient, includeSenderDetails, branding, }: DocumentSigningPageViewV1Props) => { @@ -133,34 +140,6 @@ export const DocumentSigningPageViewV1 = ({ const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId); const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; - const nextRecipient = useMemo(() => { - if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') { - return undefined; - } - - const sortedRecipients = [...allRecipients].sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; - }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; - }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); - const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted); const hasPendingFields = pendingFields.length > 0; 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 f0b00fec9..631123b6a 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 @@ -6,6 +6,8 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; +import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups'; +import { isRecipientBefore } from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types'; import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client'; @@ -236,12 +238,16 @@ export const EnvelopeSigningProvider = ({ }, [envelopeData.recipient.fields]); /** - * Assistant recipients are those that have a signing order after the assistant. + * Assistant recipients are those positioned strictly after the assistant — + * never their own group peers. */ - const assistantRecipients = - recipient.role === RecipientRole.ASSISTANT - ? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0)) - : []; + const assistantRecipients = useMemo(() => { + if (recipient.role !== RecipientRole.ASSISTANT) { + return []; + } + + return envelope.recipients.filter((r) => isRecipientBefore(recipient, r)); + }, [envelope.recipients, recipient]); /** * Assistant fields are those fulfill all of the following: @@ -249,12 +255,11 @@ export const EnvelopeSigningProvider = ({ * - After the assistant signing order * - Are not signature fields */ - const assistantFields = - recipient.role === RecipientRole.ASSISTANT - ? assistantRecipients - .filter((r) => r.signingStatus !== SigningStatus.SIGNED) - .flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE)) - : []; + const assistantFields = useMemo(() => { + return assistantRecipients + .filter((r) => r.signingStatus !== SigningStatus.SIGNED) + .flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE)); + }, [assistantRecipients]); /** * The recipient that the assistant has currently selected to sign on behalf of. @@ -269,7 +274,7 @@ export const EnvelopeSigningProvider = ({ const selectedAssistantRecipientFields = useMemo(() => { return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id); - }, [recipientFields, selectedAssistantRecipient]); + }, [assistantFields, selectedAssistantRecipient]); /** * Fields that have been completed by other recipients. @@ -290,32 +295,14 @@ export const EnvelopeSigningProvider = ({ .filter((field) => field.inserted); const nextRecipient = useMemo(() => { - if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { + if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { return null; } - const sortedRecipients = [...envelope.recipients].sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; + return getNextDictatableRecipient({ + recipients: envelope.recipients, + currentRecipientId: recipient.id, }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : null; }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); const signField = async ( 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 b3173b547..fbe513120 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 @@ -1,42 +1,30 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; -import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; -import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { + updateEditorSigners, + ZEditorRecipientsFormSchema, +} from '@documenso/lib/client-only/hooks/use-editor-recipients'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; -import { - isAssistantLastSigner, - isCcRecipient, - normalizeRecipientSigningOrders, - canRecipientBeModified as utilCanRecipientBeModified, -} from '@documenso/lib/utils/recipients'; -import { trpc } from '@documenso/trpc/react'; -import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; -import { - RecipientAutoCompleteInput, - type RecipientAutoCompleteOption, -} from '@documenso/ui/components/recipient/recipient-autocomplete-input'; -import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; +import { normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups'; +import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients'; import { cn } from '@documenso/ui/lib/utils'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; -import { Input } from '@documenso/ui/primitives/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { DragDropContext, Draggable, Droppable, type DropResult, type SensorAPI } from '@hello-pangea/dnd'; import { plural } from '@lingui/core/macro'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client'; -import { motion } from 'framer-motion'; -import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react'; +import { Trans } from '@lingui/react/macro'; +import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client'; +import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useWatch } from 'react-hook-form'; import { useRevalidator, useSearchParams } from 'react-router'; @@ -45,7 +33,8 @@ import { isDeepEqual } from 'remeda'; import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog'; import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog'; import { useCurrentTeam } from '~/providers/team'; -import { useCspNonce } from '~/utils/nonce'; + +import { RecipientStepList } from './recipient-step-list'; export const EnvelopeEditorRecipientForm = () => { const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } = @@ -53,9 +42,7 @@ export const EnvelopeEditorRecipientForm = () => { const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); - const cspNonce = useCspNonce(); - const { t } = useLingui(); const { toast } = useToast(); const { remaining } = useLimits(); const { sessionData } = useOptionalSession(); @@ -63,7 +50,6 @@ export const EnvelopeEditorRecipientForm = () => { const user = sessionData?.user; const [searchParams, setSearchParams] = useSearchParams(); - const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); // AI recipient detection dialog state @@ -109,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => { }); }; - const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); - - const $sensorApi = useRef(null); const isFirstRender = useRef(true); - const { recipients, fields } = envelope; - - const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( - { - query: debouncedRecipientSearchQuery, - }, - { - enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded, - retry: false, - }, - ); - - const recipientSuggestions = recipientSuggestionsData?.results || []; + const { recipients } = envelope; const { form } = editorRecipients; @@ -163,15 +134,16 @@ export const EnvelopeEditorRecipientForm = () => { }, [watchedSigners]); const normalizeSigningOrders = (signers: typeof watchedSigners) => { - return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id)); + return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id)); }; - const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length; - - const { fields: signers, remove: removeSigner } = useFieldArray({ + // Keep a mounted field array for `signers` so react-hook-form reconciles + // whole-array `setValue` calls atomically. Without it, reordering the array + // leaves stale partial entries in watched values (missing email/name/role), + // which breaks validation and the autosave sync. + useFieldArray({ control, name: 'signers', - keyName: 'nativeId', }); const emptySignerIndex = watchedSigners.findIndex( @@ -185,39 +157,22 @@ export const EnvelopeEditorRecipientForm = () => { const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName); + // Note: Watched signer entries can be transiently partial while react-hook-form + // re-registers reordered array fields, so guard optional access here. const isUserAlreadyARecipient = watchedSigners.some( - (signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(), + (signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(), ); const hasDocumentBeenSent = recipients.some( (recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT, ); - const canRecipientBeModified = (recipientId?: number) => { - if (envelope.type === EnvelopeType.TEMPLATE) { - return true; - } - - if (recipientId === undefined) { - return true; - } - - const recipient = recipients.find((recipient) => recipient.id === recipientId); - - if (!recipient) { - return false; - } - - return utilCanRecipientBeModified(recipient, fields); - }; + const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId); const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => { const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]); - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); if (shouldFocus) { const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId); @@ -235,20 +190,17 @@ export const EnvelopeEditorRecipientForm = () => { email: '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: activeRecipientCount + 1, + signingOrder: undefined, }); }; const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => { const currentSigners = form.getValues('signers'); - let nextSigningOrder = - currentSigners.length > 0 ? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1 : 1; - // If the only signer is the default empty signer lets just replace it with the detected recipients if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) { - form.setValue( - 'signers', + updateEditorSigners( + form, detectedRecipients.map((recipient, index) => ({ formId: nanoid(12), name: recipient.name, @@ -257,10 +209,6 @@ export const EnvelopeEditorRecipientForm = () => { actionAuth: [], signingOrder: index + 1, })), - { - shouldValidate: true, - shouldDirty: true, - }, ); return; @@ -281,16 +229,11 @@ export const EnvelopeEditorRecipientForm = () => { email: recipient.email, role: recipient.role, actionAuth: [], - signingOrder: nextSigningOrder, + signingOrder: undefined, }); - - nextSigningOrder += 1; } - form.setValue('signers', normalizeSigningOrders(currentSigners), { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, normalizeSigningOrders(currentSigners)); toast({ title: plural(detectedRecipients.length, { @@ -304,32 +247,6 @@ export const EnvelopeEditorRecipientForm = () => { }); }; - const onRemoveSigner = (index: number) => { - const signer = signers[index]; - - if (!canRecipientBeModified(signer.id)) { - toast({ - title: t`Cannot remove signer`, - description: t`This signer has already signed the document.`, - variant: 'destructive', - }); - - return; - } - - const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId); - if (formStateIndex !== -1) { - removeSigner(formStateIndex); - - const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId); - - form.setValue('signers', normalizeSigningOrders(updatedSigners), { - shouldValidate: true, - shouldDirty: true, - }); - } - }; - const onAddSelfSigner = () => { if (emptySignerIndex !== -1) { setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', { @@ -350,7 +267,7 @@ export const EnvelopeEditorRecipientForm = () => { email: currentEditorEmail ?? '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: activeRecipientCount + 1, + signingOrder: undefined, }, true, ); @@ -359,142 +276,6 @@ export const EnvelopeEditorRecipientForm = () => { } }; - const handleRecipientAutoCompleteSelect = (index: number, suggestion: RecipientAutoCompleteOption) => { - setValue(`signers.${index}.email`, suggestion.email, { - shouldValidate: true, - shouldDirty: true, - }); - setValue(`signers.${index}.name`, suggestion.name || '', { - shouldValidate: true, - shouldDirty: true, - }); - }; - - const onDragEnd = useCallback( - async (result: DropResult) => { - if (!result.destination) { - return; - } - - const items = Array.from(watchedSigners); - const [reorderedSigner] = items.splice(result.source.index, 1); - - // Find next valid position - let insertIndex = result.destination.index; - while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) { - insertIndex++; - } - - items.splice(insertIndex, 0, reorderedSigner); - - const updatedSigners = normalizeSigningOrders(items); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - - await form.trigger('signers'); - }, - [form, canRecipientBeModified, watchedSigners, toast], - ); - - const handleRoleChange = useCallback( - (index: number, role: RecipientRole) => { - const currentSigners = form.getValues('signers'); - const signingOrder = form.getValues('signingOrder'); - - // Handle parallel to sequential conversion for assistants - if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { - form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { - shouldValidate: true, - shouldDirty: true, - }); - toast({ - title: t`Signing order is enabled.`, - description: t`You cannot add assistants when signing order is disabled.`, - variant: 'destructive', - }); - return; - } - - const updatedSigners = normalizeSigningOrders( - currentSigners.map((signer, idx) => ({ - ...signer, - role: idx === index ? role : signer.role, - })), - ); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - }, - [form, toast, canRecipientBeModified], - ); - - const handleSigningOrderChange = useCallback( - (index: number, newOrderString: string) => { - const trimmedOrderString = newOrderString.trim(); - if (!trimmedOrderString) { - return; - } - - const newOrder = Number(trimmedOrderString); - if (!Number.isInteger(newOrder) || newOrder < 1) { - return; - } - - const currentSigners = form.getValues('signers'); - const signer = currentSigners[index]; - - if (isCcRecipient(signer)) { - return; - } - - const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s)); - const ccSigners = currentSigners.filter((s) => isCcRecipient(s)); - const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId); - - if (currentSigningOrderIndex === -1) { - return; - } - - const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1); - const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length); - nonCcSigners.splice(newPosition, 0, reorderedSigner); - - const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - }, - [form, canRecipientBeModified, toast], - ); - const handleSigningOrderDisable = useCallback(() => { setShowSigningOrderConfirmation(false); @@ -506,10 +287,8 @@ export const EnvelopeEditorRecipientForm = () => { })), ); - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { shouldValidate: true, shouldDirty: true, @@ -537,14 +316,17 @@ 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. + // Locked recipients hold persisted values the server refuses to rewrite, + // e.g. an envelope created via API with no signing order where a recipient + // has already signed. Restore their PERSISTED order so form normalization + // drift never submits a "changed" locked recipient the server rejects. const envelopeRecipients = data.signers.map((recipient) => { if (!canRecipientBeModified(recipient.id)) { + const persistedRecipient = recipients.find((envelopeRecipient) => envelopeRecipient.id === recipient.id); + return { ...recipient, - signingOrder: recipient.signingOrder, + signingOrder: persistedRecipient?.signingOrder ?? undefined, }; } return recipient; @@ -570,7 +352,7 @@ export const EnvelopeEditorRecipientForm = () => { signer.email !== recipient.email || signer.name !== recipient.name || signer.role !== recipient.role || - signer.signingOrder !== recipient.signingOrder || + (signer.signingOrder ?? null) !== (recipient.signingOrder ?? null) || !isDeepEqual(signerActionAuth, recipientActionAuth) ); }); @@ -590,7 +372,7 @@ export const EnvelopeEditorRecipientForm = () => { }, [formValues]); const recipientCountLimit = organisation.organisationClaim.recipientCount; - const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit; + const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit; return ( @@ -646,7 +428,7 @@ export const EnvelopeEditorRecipientForm = () => { type="button" className="flex-1" size="sm" - disabled={isSubmitting || signers.length >= remaining.recipients} + disabled={isSubmitting || watchedSigners.length >= remaining.recipients} onClick={() => onAddSigner()} > @@ -796,288 +578,7 @@ export const EnvelopeEditorRecipientForm = () => { )} - { - $sensorApi.current = api; - }, - ]} - > - - {(provided) => ( -
- {signers.map((signer, index) => { - const isDirectRecipient = - envelope.type === EnvelopeType.TEMPLATE && - envelope.directLink !== null && - signer.id === envelope.directLink.directTemplateRecipientId; - - return ( - - {(provided, snapshot) => ( -
- -
- {isSigningOrderSequential && isCcRecipient(signer) && ( -
- )} - - {isSigningOrderSequential && !isCcRecipient(signer) && ( - ( - - - - { - field.onChange(e); - handleSigningOrderChange(index, e.target.value); - }} - onBlur={(e) => { - field.onBlur(); - handleSigningOrderChange(index, e.target.value); - }} - disabled={ - snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id) - } - /> - - - - )} - /> - )} - - ( - - {!showAdvancedSettings && index === 0 && ( - - Email - - )} - - - - handleRecipientAutoCompleteSelect(index, suggestion) - } - onSearchQueryChange={(query) => { - field.onChange(query); - setRecipientSearchQuery(query); - }} - loading={isLoading} - data-testid="signer-email-input" - maxLength={254} - /> - - - - - )} - /> - - ( - - {!showAdvancedSettings && index === 0 && ( - - Name - - )} - - - - handleRecipientAutoCompleteSelect(index, suggestion) - } - onSearchQueryChange={(query) => { - field.onChange(query); - setRecipientSearchQuery(query); - }} - loading={isLoading} - maxLength={255} - /> - - - - - )} - /> - - ( - - - { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole); - }} - disabled={ - snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id) - } - /> - - - - - )} - /> - - -
- - {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( - ( - - - - - - - - )} - /> - )} - -
- )} - - ); - })} - - {provided.placeholder} -
- )} - - + void; + onRemove: (signerIndex: number) => void; + onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void; + onSearchQueryChange: (query: string) => void; +}; + +const RecipientRowInner = ({ + signerIndex, + signer, + isSequential, + isInputDisabled, + canBeModified, + isRemoveDisabled, + showAdvancedSettings, + dragHandleProps, + recipientSuggestions, + isLoadingSuggestions, + onRoleChange, + onRemove, + onAutoCompleteSelect, + onSearchQueryChange, +}: RecipientRowProps) => { + const { t } = useLingui(); + + const { envelope, editorConfig } = useCurrentEnvelopeEditor(); + const organisation = useCurrentOrganisation(); + + const form = useFormContext(); + + const { isSubmitting } = form.formState; + + const isDirectRecipient = + envelope.type === EnvelopeType.TEMPLATE && + envelope.directLink !== null && + signer.id === envelope.directLink.directTemplateRecipientId; + + const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified; + + const rowErrors = form.formState.errors.signers?.[signerIndex]; + + return ( +
+
+ {isSequential && !isCcRecipient(signer) && ( + + + + )} + + ( + + + onAutoCompleteSelect(signerIndex, suggestion)} + onSearchQueryChange={(query) => { + field.onChange(query); + onSearchQueryChange(query); + }} + loading={isLoadingSuggestions} + data-testid="signer-email-input" + maxLength={254} + /> + + + + + )} + /> + + ( + + + onAutoCompleteSelect(signerIndex, suggestion)} + onSearchQueryChange={(query) => { + field.onChange(query); + onSearchQueryChange(query); + }} + loading={isLoadingSuggestions} + maxLength={255} + /> + + + + + )} + /> + + ( + + + { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + onRoleChange(signerIndex, value as RecipientRole); + }} + disabled={isFieldDisabled} + /> + + + + + )} + /> + + +
+ + {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( + ( + + + + + + + + )} + /> + )} +
+ ); +}; + +/** + * Memoized: rows contain heavy inputs (autocomplete, role select) and would + * otherwise re-render on every drag state change, making drags feel sluggish. + * All callback props are stable (useCallback in the list) and `signer` object + * identities only change when form values actually change. + */ +export const RecipientRow = memo(RecipientRowInner); diff --git a/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx b/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx new file mode 100644 index 000000000..579684dea --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx @@ -0,0 +1,261 @@ +import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import type { RecipientStep } from '@documenso/lib/utils/recipient-groups'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd'; +import { Draggable, Droppable } from '@hello-pangea/dnd'; +import { Plural, Trans } from '@lingui/react/macro'; +import { GripVerticalIcon, Users2Icon } from 'lucide-react'; + +import { RecipientRow, type RecipientRowProps } from './recipient-row'; + +type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; + +export type DraggingType = 'STEP' | 'RECIPIENT' | null; + +/** + * Skips the drop animation. The post-drop state update re-sorts and renumbers + * the groups anyway, so gliding to the predicted slot first makes every drop + * feel like it settles twice — snapping hands control to the real re-render + * immediately instead. + */ +const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { + if (!snapshot.isDropAnimating) { + return provided.draggableProps.style; + } + + return { + ...provided.draggableProps.style, + transitionDuration: '0.001s', + }; +}; + +export type RecipientStepCardSharedRowProps = Pick< + RecipientRowProps, + | 'showAdvancedSettings' + | 'recipientSuggestions' + | 'isLoadingSuggestions' + | 'onRoleChange' + | 'onRemove' + | 'onAutoCompleteSelect' + | 'onSearchQueryChange' +>; + +export type RecipientStepCardProps = { + stepIndex: number; + step: RecipientStep; + isLastStep: boolean; + draggableProvided: DraggableProvided; + draggableSnapshot: DraggableStateSnapshot; + draggingType: DraggingType; + /** + * Whether recipients may be combined into signing groups. False on CSC + * (AES/QES) instances, where every signing recipient must hold a distinct + * step. Constant for the session, so disabling the drop-zone with it does + * not violate the "never toggle `isDropDisabled` mid-drag" constraint. + */ + isGroupingEnabled: boolean; + isStepLocked: boolean; + isRemoveDisabled: boolean; + flatIndexByFormId: Map; + canSignerBeModified: (signer: TEditorSigner) => boolean; + isSubmitting: boolean; + onUngroup: (stepIndex: number) => void; + rowProps: RecipientStepCardSharedRowProps; +}; + +/** + * The drop-zone strip rendered above each group card (and below the last one) + * that receives recipient-row drops. Invisible until a dragged row hovers it, + * then it shows a full-width green line marking the insertion point. + * + * Notes: + * - It lives INSIDE the step's Draggable so it shifts together with the card + * while groups are being reordered — a static strip between draggables + * would stay behind while the cards around it are displaced, making group + * drags look broken. + * - Its `droppableId` must stay STABLE while mounted (anchored to a formId, + * never a positional index): @hello-pangea/dnd does not support changing + * ids on mounted droppables/draggables, which silently breaks them. + * - `type="RECIPIENT"` already scopes it to recipient-row drags, and + * `isDropDisabled` must not be toggled based on the active drag, as + * @hello-pangea/dnd snapshots it at drag start (before state updates land). + * - It must keep a CONSTANT size: droppable geometry is captured when a drag + * starts, so resizing during the drag would leave the visible strip and the + * actual hit area in different places. Only colors may change mid-drag. + */ +const RecipientStepGap = ({ droppableId }: { droppableId: string }) => ( + + {(provided, snapshot) => ( +
+
+ {provided.placeholder} +
+ )} + +); + +export const RecipientStepCard = ({ + stepIndex, + step, + isLastStep, + draggableProvided, + draggableSnapshot, + draggingType, + isGroupingEnabled, + isStepLocked, + isRemoveDisabled, + flatIndexByFormId, + canSignerBeModified, + isSubmitting, + onUngroup, + rowProps, +}: RecipientStepCardProps) => { + const isGroup = step.members.length > 1; + const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor); + + const stepLabel = step.order ?? stepIndex + 1; + + // All droppable ids are anchored to the first member's formId (never a + // positional index) so they stay stable while cards are reordered — + // @hello-pangea/dnd does not support changing ids on mounted elements. + const stepAnchor = step.members[0].formId; + + return ( +
+ + + + {(droppableProvided, droppableSnapshot) => { + const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver; + const isHighlighted = isCombineTarget || isJoinTarget; + + return ( +
+ {isHighlighted && ( + + + Release to group + + )} + +
+ + + + + + Group {stepLabel} + + + {isGroup && ( + <> + + + + + + + + )} +
+ + {step.members.map((member, memberIndex) => { + const signerIndex = flatIndexByFormId.get(member.formId) ?? -1; + const canBeModified = canSignerBeModified(member); + + return ( + + {(memberProvided, memberSnapshot) => ( +
+ +
+ )} +
+ ); + })} + + {droppableProvided.placeholder} +
+ ); + }} +
+ + {isLastStep && } +
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx new file mode 100644 index 000000000..9c4f69ff3 --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx @@ -0,0 +1,398 @@ +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { + type TEditorRecipientsFormSchema, + updateEditorSigners, +} from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { + extractRecipientToNewStep, + getLastLockedStepIndex, + groupRecipientsBySigningOrder, + isSigningOrderFrozen, + mergeSteps, + moveRecipientToStep, + normalizeGroupedSigningOrders, + reorderStep, + ungroupStep, +} from '@documenso/lib/utils/recipient-groups'; +import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients'; +import { trpc } from '@documenso/trpc/react'; +import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import type { BeforeCapture, DropResult } from '@hello-pangea/dnd'; +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; +import { useCallback, useMemo, useState } from 'react'; + +import { useCspNonce } from '~/utils/nonce'; + +import { RecipientRow } from './recipient-row'; +import { type DraggingType, RecipientStepCard } from './recipient-step-card'; + +type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; + +export type RecipientStepListProps = { + showAdvancedSettings: boolean; +}; + +export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + const cspNonce = useCspNonce(); + + const { envelope, editorRecipients, isEmbedded, isCscMode } = useCurrentEnvelopeEditor(); + const { form } = editorRecipients; + + // Signing groups are an SES feature: TSP (AES/QES) signatures must be + // strictly sequential, so on CSC instances the group affordances (card + // combine, row-to-card join) are disabled while step reordering and + // ungrouping of invalid API-created state stay available. + const isGroupingEnabled = !isCscMode; + + const [draggingType, setDraggingType] = useState(null); + const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); + + const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); + + const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( + { + query: debouncedRecipientSearchQuery, + }, + { + enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded, + retry: false, + }, + ); + + const recipientSuggestions = recipientSuggestionsData?.results || []; + + const watchedSigners = form.watch('signers'); + const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; + const { isSubmitting } = form.formState; + + const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]); + + // Signing is sequential, so anyone who has already acted is at or before the + // current step. Those steps hold persisted orders that cannot be rewritten, + // so ordering is locked up to and including the last of them; everything + // after can still be rearranged freely. + const lastLockedStepIndex = useMemo( + () => getLastLockedStepIndex(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)), + [steps, envelope], + ); + + const isOrderingFrozen = useMemo( + () => isSigningOrderFrozen(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)), + [steps, envelope], + ); + + const isRemoveDisabled = watchedSigners.length === 1; + + const flatIndexByFormId = useMemo( + () => new Map(watchedSigners.map((signer, index) => [signer.formId, index])), + [watchedSigners], + ); + + const canSignerBeModified = useCallback( + (signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id), + [envelope], + ); + + const applySigners = useCallback( + (updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => { + const { warnWhenAssistantLast = true } = options; + + updateEditorSigners(form, updatedSigners); + + if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) { + toast({ + title: t`Warning: Assistant as last signer`, + description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + }); + } + + void form.trigger('signers'); + }, + [form, t, toast], + ); + + const handleRoleChange = useCallback( + (signerIndex: number, role: RecipientRole) => { + const currentSigners = form.getValues('signers'); + const signingOrder = form.getValues('signingOrder'); + + if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { + form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { + shouldValidate: true, + shouldDirty: true, + }); + + toast({ + title: t`Signing order is enabled.`, + description: t`You cannot add assistants when signing order is disabled.`, + variant: 'destructive', + }); + + return; + } + + const updatedSigners = normalizeGroupedSigningOrders( + currentSigners.map((signer, index) => ({ + ...signer, + role: index === signerIndex ? role : signer.role, + })), + canSignerBeModified, + ); + + applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT }); + }, + [form, toast, t, canSignerBeModified, applySigners], + ); + + const handleRemove = useCallback( + (signerIndex: number) => { + const signer = form.getValues('signers')[signerIndex]; + + if (!signer) { + return; + } + + if (!canSignerBeModified(signer)) { + toast({ + title: t`Cannot remove signer`, + description: t`This signer has already signed the document.`, + variant: 'destructive', + }); + + return; + } + + const updatedSigners = normalizeGroupedSigningOrders( + form.getValues('signers').filter((s) => s.formId !== signer.formId), + canSignerBeModified, + ); + + applySigners(updatedSigners, { warnWhenAssistantLast: false }); + }, + [form, toast, t, canSignerBeModified, applySigners], + ); + + const handleUngroup = useCallback( + (stepIndex: number) => { + applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified)); + }, + [form, canSignerBeModified, applySigners], + ); + + const handleAutoCompleteSelect = useCallback( + (signerIndex: number, suggestion: RecipientAutoCompleteOption) => { + form.setValue(`signers.${signerIndex}.email`, suggestion.email, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', { + shouldValidate: true, + shouldDirty: true, + }); + }, + [form], + ); + + const onBeforeCapture = useCallback((before: BeforeCapture) => { + setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT'); + }, []); + + const onDragEnd = useCallback( + (result: DropResult) => { + setDraggingType(null); + + const currentSigners = form.getValues('signers'); + + // Drag-and-drop ids are anchored to the first member's formId so they + // stay stable across reorders; resolve them back to step indexes here. + const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners); + + const findStepIndexByAnchor = (anchorFormId: string) => + currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId); + + if (result.type === 'STEP') { + if (result.combine) { + // Unreachable while combining is disabled, but kept as a guard so a + // stray combine result can never form a group on a CSC envelope. + if (!isGroupingEnabled) { + return; + } + + const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length)); + + if (targetStepIndex === -1) { + return; + } + + applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified)); + + return; + } + + if (result.destination) { + applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified)); + } + + return; + } + + if (result.type === 'RECIPIENT' && result.destination) { + const formId = result.draggableId.slice('recipient-'.length); + const { droppableId } = result.destination; + + if (droppableId === 'gap-end') { + applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified)); + + return; + } + + if (droppableId.startsWith('gap-')) { + const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length)); + + if (insertStepIndex === -1) { + return; + } + + applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified)); + + return; + } + + if (droppableId.startsWith('step-members-')) { + // Unreachable while the card drop-zones are disabled, but kept as a + // guard so a stray drop can never form a group on a CSC envelope. + if (!isGroupingEnabled) { + return; + } + + const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length)); + + if (targetStepIndex === -1) { + return; + } + + applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified)); + } + } + }, + [form, canSignerBeModified, applySigners, isGroupingEnabled], + ); + + const sharedRowProps = { + showAdvancedSettings, + recipientSuggestions, + isLoadingSuggestions: isLoading, + onRoleChange: handleRoleChange, + onRemove: handleRemove, + onAutoCompleteSelect: handleAutoCompleteSelect, + onSearchQueryChange: setRecipientSearchQuery, + }; + + return ( +
+ {!showAdvancedSettings && !isSequential && ( +
+ + Email + + + Name + + +
+ )} + + {!isSequential ? ( +
+ {watchedSigners.map((signer, index) => ( + + ))} +
+ ) : ( + <> + + + {(provided) => ( +
+ {steps.map((step, stepIndex) => { + const isStepLocked = isOrderingFrozen || stepIndex <= lastLockedStepIndex; + + return ( + + {(draggableProvided, draggableSnapshot) => ( + + )} + + ); + })} + + {provided.placeholder} +
+ )} +
+
+ + {ccRecipients.length > 0 && ( +
+ + Receives Copy + + + {ccRecipients.map((signer) => ( +
+ +
+ ))} +
+ )} + + )} +
+ ); +}; diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index e317068df..b7f1cafd4 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -149,7 +149,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {