import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DEFAULT_EDITOR_CONFIG, type EnvelopeEditorConfig, type TEditorEnvelope, } from '@documenso/lib/types/envelope-editor'; import { trpc } from '@documenso/trpc/react'; import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types'; import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types'; import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import { getRecipientColor } from '@documenso/ui/lib/recipient-colors'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useLingui } from '@lingui/react/macro'; import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client'; import type React from 'react'; import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import type { TDocumentEmailSettings } from '../../types/document-email'; import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams'; import type { TLocalField } from '../hooks/use-editor-fields'; import { useEditorFields } from '../hooks/use-editor-fields'; import { useEditorRecipients } from '../hooks/use-editor-recipients'; import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave'; export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview'; type UpdateEnvelopePayload = Pick; type EnvelopeEditorProviderValue = { editorConfig: EnvelopeEditorConfig; envelope: TEditorEnvelope; isEmbedded: boolean; isDocument: boolean; isTemplate: boolean; /** * Whether the instance is running in CSC (Cloud Signature Consortium) mode. * Components can branch on this for any additional CSC-only UI gating * beyond the overrides already baked into `editorConfig`. */ isCscMode: boolean; setLocalEnvelope: (localEnvelope: Partial) => void; updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void; updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise; setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void; setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise; getRecipientColorKey: (recipientId: number) => TRecipientColor; editorFields: ReturnType; editorRecipients: ReturnType; isAutosaving: boolean; flushAutosave: () => Promise; autosaveError: boolean; resetForms: () => void; relativePath: { basePath: string; envelopePath: string; editorPath: string; documentRootPath: string; templateRootPath: string; }; navigateToStep: (step: EnvelopeEditorStep) => void; syncEnvelope: () => Promise; registerExternalFlush: (key: string, flush: () => Promise) => () => void; registerPendingMutation: (promise: Promise) => void; organisationEmails?: { id: string; email: string }[]; }; interface EnvelopeEditorProviderProps { children: React.ReactNode; editorConfig?: EnvelopeEditorConfig; initialEnvelope: TEditorEnvelope; organisationEmails?: { id: string; email: string }[]; } const EnvelopeEditorContext = createContext(null); export const useCurrentEnvelopeEditor = () => { const context = useContext(EnvelopeEditorContext); if (!context) { throw new Error('useCurrentEnvelopeEditor must be used within a EnvelopeEditorProvider'); } return context; }; export const EnvelopeEditorProvider = ({ children, editorConfig: providedEditorConfig = DEFAULT_EDITOR_CONFIG, initialEnvelope, organisationEmails, }: EnvelopeEditorProviderProps) => { const { t } = useLingui(); const { toast } = useToast(); const [_searchParams, setSearchParams] = useSearchParams(); const [envelope, _setEnvelope] = useState(initialEnvelope); const [autosaveError, setAutosaveError] = useState(false); const isCscMode = IS_INSTANCE_CSC_MODE(); /** * CSC-mode overrides applied on top of any caller-supplied editor config. * TSP envelopes are forced SEQUENTIAL at send-time and the sign path has no * nextSigner dictation; the assistant role's pre-fill semantics don't map * onto each recipient signing their own complete PDF state. Hide all three * up-front so authors don't pick options that would get silently coerced. */ const editorConfig = useMemo(() => { if (!isCscMode || !providedEditorConfig.recipients) { return providedEditorConfig; } return { ...providedEditorConfig, recipients: { ...providedEditorConfig.recipients, allowConfigureSigningOrder: false, allowConfigureDictateNextSigner: false, allowAssistantRole: false, }, }; }, [isCscMode, providedEditorConfig]); const envelopeRef = useRef(initialEnvelope); const externalFlushCallbacksRef = useRef Promise>>(new Map()); const pendingMutationsRef = useRef>>(new Set()); const registerExternalFlush = useCallback((key: string, flush: () => Promise) => { externalFlushCallbacksRef.current.set(key, flush); return () => { externalFlushCallbacksRef.current.delete(key); }; }, []); const registerPendingMutation = useCallback((promise: Promise) => { pendingMutationsRef.current.add(promise); void promise.finally(() => { pendingMutationsRef.current.delete(promise); }); }, []); const setEnvelope: typeof _setEnvelope = (action) => { _setEnvelope((prev) => { const next = typeof action === 'function' ? action(prev) : action; envelopeRef.current = next; return next; }); }; const isEmbedded = editorConfig.embedded !== undefined; const editorFields = useEditorFields({ envelope, handleFieldsUpdate: (fields) => setFieldsDebounced(fields), }); const editorRecipients = useEditorRecipients({ envelope, }); const setRecipientsMutation = trpc.envelope.recipient.set.useMutation(); const setFieldsMutation = trpc.envelope.field.set.useMutation(); const updateEnvelopeMutation = trpc.envelope.update.useMutation(); /** * Handles debouncing the recipients updates to the server. * * Will set the local envelope recipients and fields after the update is complete. */ const { triggerSave: setRecipientsDebounced, flush: flushSetRecipients, isPending: isRecipientsMutationPending, } = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => { try { let recipients: TEditorEnvelope['recipients'] = []; if (!isEmbedded) { const response = await setRecipientsMutation.mutateAsync({ envelopeId: envelope.id, envelopeType: envelope.type, recipients: localRecipients, }); recipients = response.data; } else { recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients }); } setEnvelope((prev) => ({ ...prev, recipients, fields: prev.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)), })); // Reset the local fields to ensure deleted recipient fields are removed. editorFields.resetForm( envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)), ); setAutosaveError(false); } catch (err) { console.error(err); setAutosaveError(true); toast({ title: t`Save failed`, description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`, variant: 'destructive', duration: 7500, }); } }, 1000); const setRecipientsAsync = async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => { setRecipientsDebounced(localRecipients); await flushSetRecipients(); }; /** * Handles debouncing the fields updates to the server. * * Will set the local envelope fields after the update is complete. */ const { triggerSave: setFieldsDebounced, flush: flushSetFields, isPending: isFieldsMutationPending, } = useEnvelopeAutosave(async (localFields: TLocalField[]) => { try { let fields: TSetEnvelopeFieldsResponse['data'] = []; if (!isEmbedded) { const response = await setFieldsMutation.mutateAsync({ envelopeId: envelope.id, envelopeType: envelope.type, fields: localFields, }); fields = response.data; } else { fields = mapLocalFieldsToFields({ envelope, localFields }); } setEnvelope((prev) => ({ ...prev, fields, })); setAutosaveError(false); // Insert the IDs into the local fields. fields.forEach((field) => { const localField = localFields.find((localField) => localField.formId === field.formId); if (localField && !localField.id) { localField.id = field.id; editorFields.setFieldId(localField.formId, field.id); } }); } catch (err) { console.error(err); setAutosaveError(true); toast({ title: t`Save failed`, description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`, variant: 'destructive', duration: 7500, }); } }, 2000); const setFieldsAsync = async (localFields: TLocalField[]) => { setFieldsDebounced(localFields); await flushSetFields(); }; /** * Handles debouncing the envelope updates to the server. * * Will set the local envelope after the update is complete. */ const { triggerSave: updateEnvelopeDebounced, flush: flushUpdateEnvelope, isPending: isEnvelopeMutationPending, } = useEnvelopeAutosave(async ({ data, meta }: UpdateEnvelopePayload) => { try { const response = !isEmbedded ? await updateEnvelopeMutation.mutateAsync({ envelopeId: envelope.id, data, meta, }) : {}; setEnvelope((prev) => ({ ...prev, ...data, authOptions: { globalAccessAuth: data?.globalAccessAuth || [], globalActionAuth: data?.globalActionAuth || [], }, ...response, documentMeta: { ...prev.documentMeta, ...meta, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions emailSettings: (meta?.emailSettings || null) as unknown as TDocumentEmailSettings | null, }, })); setAutosaveError(false); } catch (err) { console.error(err); setAutosaveError(true); toast({ title: t`Save failed`, description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`, variant: 'destructive', duration: 7500, }); } }, 1000); const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => { updateEnvelopeDebounced(envelopeUpdates); await flushUpdateEnvelope(); }; /** * Updates the local envelope and debounces the update to the server. * * Use this when you want to update the local envelope immediately while debouncing * the actual update to the server. */ const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => { setEnvelope((prev) => ({ ...prev, ...envelopeUpdates.data, meta: { ...prev.documentMeta, ...envelopeUpdates.meta, }, })); updateEnvelopeDebounced(envelopeUpdates); }; const getRecipientColorKey = useCallback( (recipientId: number) => getRecipientColor(envelope.recipients.findIndex((r) => r.id === recipientId)), [envelope.recipients], ); const { refetch: reloadEnvelope } = trpc.envelope.editor.get.useQuery( { envelopeId: envelope.id, }, { enabled: !isEmbedded, gcTime: 0, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, }, ); /** * Fetch and sync the envelope back into the editor. * * Overrides everything. */ const syncEnvelope = async () => { await flushAutosave(); // Bypass syncing for embedded mode. if (isEmbedded) { return; } const fetchedEnvelopeData = await reloadEnvelope(); if (fetchedEnvelopeData.data) { setEnvelope(fetchedEnvelopeData.data); editorRecipients.resetForm({ recipients: fetchedEnvelopeData.data.recipients, documentMeta: fetchedEnvelopeData.data.documentMeta, }); editorFields.resetForm(fetchedEnvelopeData.data.fields); } }; const setLocalEnvelope = (localEnvelope: Partial) => { setEnvelope((prev) => ({ ...prev, ...localEnvelope })); }; const isAutosaving = useMemo(() => { return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending; }, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]); const relativePath = useMemo(() => { let documentRootPath = formatDocumentsPath(envelope.team.url); let templateRootPath = formatTemplatesPath(envelope.team.url); const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath; let envelopePath = `${basePath}/${envelope.id}`; let editorPath = `${basePath}/${envelope.id}/edit`; if (editorConfig.embedded) { let embeddedEditorPath = editorConfig.embedded.mode === 'edit' ? `/embed/v2/authoring/envelope/edit/${envelope.id}` : `/embed/v2/authoring/envelope/create`; embeddedEditorPath += `?token=${editorConfig.embedded.presignToken}`; envelopePath = embeddedEditorPath; editorPath = embeddedEditorPath; documentRootPath = embeddedEditorPath; templateRootPath = embeddedEditorPath; } return { basePath, envelopePath, editorPath, documentRootPath, templateRootPath, }; }, [envelope.type, envelope.id]); const navigateToStep = (step: EnvelopeEditorStep) => { setSearchParams((prev) => { const newParams = new URLSearchParams(prev); if (step === 'upload') { newParams.delete('step'); } else { newParams.set('step', step); } return newParams; }); }; const resetForms = () => { editorRecipients.resetForm({ recipients: envelopeRef.current.recipients, documentMeta: envelopeRef.current.documentMeta, }); editorFields.resetForm(envelopeRef.current.fields); }; const flushAutosave = async (): Promise => { await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]); // Flush all registered external flushes (e.g., upload page's debounced item updates). const externalFlushes = Array.from(externalFlushCallbacksRef.current.values()); await Promise.all(externalFlushes.map(async (flush) => flush())); // Await all registered pending mutations (e.g., in-flight creates/deletes). // Use allSettled so a single failed mutation doesn't prevent awaiting the rest. if (pendingMutationsRef.current.size > 0) { await Promise.allSettled(Array.from(pendingMutationsRef.current)); } return envelopeRef.current; }; return ( {children} ); }; type MapLocalRecipientsToRecipientsOptions = { envelope: TEditorEnvelope; localRecipients: TSetEnvelopeRecipientsRequest['recipients']; }; const mapLocalRecipientsToRecipients = ({ envelope, localRecipients, }: MapLocalRecipientsToRecipientsOptions): TEditorEnvelope['recipients'] => { let smallestRecipientId = localRecipients.reduce((min, recipient) => { if (recipient.id && recipient.id < min) { return recipient.id; } return min; }, -1); return localRecipients.map((recipient) => { const foundRecipient = envelope.recipients.find((r) => r.id === recipient.id); let recipientId = recipient.id; if (recipientId === undefined) { recipientId = smallestRecipientId; smallestRecipientId--; } return { id: recipientId, envelopeId: envelope.id, email: recipient.email, name: recipient.name, token: foundRecipient?.token || '', documentDeletedAt: foundRecipient?.documentDeletedAt || null, expired: foundRecipient?.expired || null, signedAt: foundRecipient?.signedAt || null, authOptions: recipient.actionAuth.length > 0 ? { actionAuth: recipient.actionAuth, accessAuth: [] } : null, signingOrder: recipient.signingOrder ?? null, rejectionReason: foundRecipient?.rejectionReason || null, role: recipient.role, readStatus: foundRecipient?.readStatus || ReadStatus.NOT_OPENED, signingStatus: foundRecipient?.signingStatus || SigningStatus.NOT_SIGNED, sendStatus: foundRecipient?.sendStatus || SendStatus.NOT_SENT, expiresAt: foundRecipient?.expiresAt || null, expirationNotifiedAt: foundRecipient?.expirationNotifiedAt || null, }; }); }; type MapLocalFieldsToFieldsOptions = { localFields: TLocalField[]; envelope: TEditorEnvelope; }; const mapLocalFieldsToFields = ({ envelope, localFields, }: MapLocalFieldsToFieldsOptions): TSetEnvelopeFieldsResponse['data'] => { let smallestFieldId = localFields.reduce((min, field) => { if (field.id && field.id < min) { return field.id; } return min; }, -1); return localFields.map((field) => { const foundField = envelope.fields.find((envelopeField) => envelopeField.id === field.id); let fieldId = field.id; if (fieldId === undefined) { fieldId = smallestFieldId; smallestFieldId--; } return { ...field, formId: field.formId, id: fieldId, envelopeId: envelope.id, envelopeItemId: field.envelopeItemId, type: field.type, recipientId: field.recipientId, positionX: new Prisma.Decimal(field.positionX), positionY: new Prisma.Decimal(field.positionY), width: new Prisma.Decimal(field.width), height: new Prisma.Decimal(field.height), secondaryId: foundField?.secondaryId || '', inserted: foundField?.inserted || false, customText: foundField?.customText || '', fieldMeta: field.fieldMeta || null, }; }); };