feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

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

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -0,0 +1,286 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { RecipientColorStyles, TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import {
AVAILABLE_RECIPIENT_COLORS,
getRecipientColorStyles,
} from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TEnvelope } from '../../types/envelope';
import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
export const useDebounceFunction = <Args extends unknown[]>(
callback: (...args: Args) => void,
delay: number,
) => {
const timeoutRef = useRef<NodeJS.Timeout>();
return useCallback(
(...args: Args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay],
);
};
type EnvelopeEditorProviderValue = {
envelope: TEnvelope;
isDocument: boolean;
isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: Partial<TEnvelope>) => void;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
getFieldColor: (field: TLocalField) => RecipientColorStyles;
getRecipientColorKey: (recipientId: number) => TRecipientColor;
editorFields: ReturnType<typeof useEditorFields>;
isAutosaving: boolean;
flushAutosave: () => void;
autosaveError: boolean;
// refetchEnvelope: () => Promise<void>;
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
};
interface EnvelopeEditorProviderProps {
children: React.ReactNode;
initialEnvelope: TEnvelope;
}
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(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,
initialEnvelope,
}: EnvelopeEditorProviderProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
console.log(input.meta?.emailSettings);
setEnvelope({
...envelope,
...response,
documentMeta: {
...envelope.documentMeta,
...input.meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (input.meta?.emailSettings ||
null) as unknown as TDocumentEmailSettings | null,
},
});
setAutosaveError(false);
},
onError: (error) => {
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,
});
},
});
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
onSuccess: () => {
setAutosaveError(false);
},
onError: (error) => {
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,
});
},
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: () => {
setAutosaveError(false);
},
onError: (error) => {
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,
});
},
});
const {
triggerSave: setRecipientsDebounced,
flush: setRecipientsAsync,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
await envelopeRecipientSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients,
});
}, 1000);
const {
triggerSave: setFieldsDebounced,
flush: setFieldsAsync,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (fields: TLocalField[]) => {
await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields,
});
}, 1000);
const {
triggerSave: setEnvelopeDebounced,
flush: setEnvelopeAsync,
isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: Partial<TEnvelope>) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: {
...envelopeUpdates,
},
});
}, 1000);
/**
* Updates the local envelope and debounces the update to the server.
*/
const updateEnvelope = (envelopeUpdates: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...envelopeUpdates }));
setEnvelopeDebounced(envelopeUpdates);
};
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const getFieldColor = useCallback(
(field: TLocalField) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === field.recipientId,
);
return getRecipientColorStyles(Math.max(recipientIndex, 0));
},
[envelope.recipients], // Todo: Envelopes - Local recipients
);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId,
);
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
},
[envelope.recipients], // Todo: Envelopes - Local recipients
);
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
{
envelopeId: envelope.id,
},
{
initialData: envelope,
},
);
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
};
const isAutosaving = useMemo(() => {
return (
envelopeFieldSetMutationQuery.isPending ||
envelopeRecipientSetMutationQuery.isPending ||
envelopeUpdateMutationQuery.isPending ||
isFieldsMutationPending ||
isRecipientsMutationPending ||
isEnvelopeMutationPending
);
}, [
envelopeFieldSetMutationQuery.isPending,
envelopeRecipientSetMutationQuery.isPending,
envelopeUpdateMutationQuery.isPending,
isFieldsMutationPending,
isRecipientsMutationPending,
isEnvelopeMutationPending,
]);
const flushAutosave = () => {
void setFieldsAsync();
void setRecipientsAsync();
void setEnvelopeAsync();
};
return (
<EnvelopeEditorContext.Provider
value={{
envelope,
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope,
getFieldColor,
getRecipientColorKey,
updateEnvelope,
setRecipientsDebounced,
setRecipientsAsync,
editorFields,
autosaveError,
flushAutosave,
isAutosaving,
}}
>
{children}
</EnvelopeEditorContext.Provider>
);
};

View File

@ -0,0 +1,148 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { DocumentData } from '@prisma/client';
import type { TEnvelope } from '../../types/envelope';
import { getFile } from '../../universal/upload/get-file';
type FileData =
| {
status: 'loading' | 'error';
}
| {
file: Uint8Array;
status: 'loaded';
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderProviderValue = {
getPdfBuffer: (documentDataId: string) => FileData | null;
envelopeItems: EnvelopeRenderItem[];
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields'];
};
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems'>;
/**
* Optional fields which are passed down to renderers for custom rendering needs.
*
* Only pass if the CustomRenderer you are passing in wants fields.
*/
fields?: TEnvelope['fields'];
}
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
export const useCurrentEnvelopeRender = () => {
const context = useContext(EnvelopeRenderContext);
if (!context) {
throw new Error('useCurrentEnvelopeRender must be used within a EnvelopeRenderProvider');
}
return context;
};
/**
* Manages fetching and storing PDF files to render on the client.
*/
export const EnvelopeRenderProvider = ({
children,
envelope,
fields,
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
);
const loadEnvelopeItemPdfFile = async (documentData: DocumentData) => {
if (files[documentData.id]?.status === 'loading') {
return;
}
if (!files[documentData.id]) {
setFiles((prev) => ({
...prev,
[documentData.id]: {
status: 'loading',
},
}));
}
try {
const file = await getFile(documentData);
setFiles((prev) => ({
...prev,
[documentData.id]: {
file,
status: 'loaded',
},
}));
} catch (error) {
console.error(error);
setFiles((prev) => ({
...prev,
[documentData.id]: {
status: 'error',
},
}));
}
};
const getPdfBuffer = useCallback(
(documentDataId: string) => {
return files[documentDataId] || null;
},
[files],
);
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
setItem(foundItem ?? null);
};
// Set the selected item to the first item if none is set.
useEffect(() => {
if (!currentItem && envelopeItems.length > 0) {
setCurrentEnvelopeItem(envelopeItems[0].id);
}
}, [currentItem, envelopeItems]);
// Look for any missing pdf files and load them.
useEffect(() => {
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.documentDataId]);
for (const item of missingFiles) {
void loadEnvelopeItemPdfFile(item.documentData);
}
}, [envelope.envelopeItems]);
return (
<EnvelopeRenderContext.Provider
value={{
getPdfBuffer,
envelopeItems,
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
}}
>
{children}
</EnvelopeRenderContext.Provider>
);
};