This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 deletions
@@ -9,7 +9,8 @@ import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
import { normalizeGroupedSigningOrders } from '../../utils/recipient-groups';
import { isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
@@ -65,10 +66,71 @@ export const ZEditorRecipientsFormSchema = z
});
}
});
const seenSigningOrders = new Set<number>();
data.signers.forEach((signer, index) => {
if (signer.role === RecipientRole.CC || typeof signer.signingOrder !== 'number') {
return;
}
if (seenSigningOrders.has(signer.signingOrder)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes do not support recipient signing groups.',
path: ['signers', index, 'signingOrder'],
});
}
seenSigningOrders.add(signer.signingOrder);
});
});
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
/**
* Replaces the signers array while keeping controlled inputs in sync.
*
* Rows are rendered with stable `formId` keys (required for drag and drop),
* so react-hook-form `Controller`s never remount and their leaf
* subscriptions are NOT re-notified by a root-level array `setValue`. Any
* value that changes while a signer keeps its index (e.g. a role change)
* must be leaf-set first so the controlled input actually re-renders.
*/
export const updateEditorSigners = (
form: UseFormReturn<TEditorRecipientsFormSchema>,
updatedSigners: TEditorRecipientsFormSchema['signers'],
) => {
const previousSigners = form.getValues('signers');
updatedSigners.forEach((signer, index) => {
const previousSigner = previousSigners[index];
// Only slot-stable signers need leaf notifications — moved signers get a
// new field name and re-subscribe with fresh values on their own.
if (!previousSigner || previousSigner.formId !== signer.formId) {
return;
}
if (previousSigner.role !== signer.role) {
form.setValue(`signers.${index}.role`, signer.role, { shouldDirty: true });
}
if (previousSigner.email !== signer.email) {
form.setValue(`signers.${index}.email`, signer.email, { shouldDirty: true });
}
if (previousSigner.name !== signer.name) {
form.setValue(`signers.${index}.name`, signer.name, { shouldDirty: true });
}
});
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
};
type EditorRecipientsProps = {
envelope: TEditorEnvelope;
};
@@ -89,19 +151,43 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
const generateDefaultValues = (options?: ResetFormOptions) => {
const { recipients, documentMeta } = options ?? {};
const formRecipients = (recipients || envelope.recipients).map((recipient, index) => ({
const sourceRecipients = sortRecipientsForSigningOrder(recipients || envelope.recipients);
// A recipient without a persisted order means "last" everywhere else — the
// server sorts NULLS LAST. Continue numbering after the highest existing
// order rather than guessing from array position: a guess can land on a
// real order, and equal orders now mean "same signing step".
let fallbackOrder = sourceRecipients.reduce(
(highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0),
0,
);
const signingOrderByRecipientId = new Map<number, number | undefined>();
for (const recipient of sourceRecipients) {
if (isCcRecipient(recipient)) {
signingOrderByRecipientId.set(recipient.id, undefined);
} else if (typeof recipient.signingOrder === 'number') {
signingOrderByRecipientId.set(recipient.id, recipient.signingOrder);
} else {
fallbackOrder += 1;
signingOrderByRecipientId.set(recipient.id, fallbackOrder);
}
}
const formRecipients = sourceRecipients.map((recipient) => ({
id: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1),
signingOrder: signingOrderByRecipientId.get(recipient.id),
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}));
const signers: TLocalRecipient[] =
formRecipients.length > 0
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
? normalizeGroupedSigningOrders(formRecipients)
: [
{
formId: initialId,
@@ -7,7 +7,10 @@ import {
} 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 {
TSetEnvelopeRecipientsRequest,
TSetEnvelopeRecipientsResponse,
} 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';
@@ -29,6 +32,14 @@ export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
type SetRecipientsPayload = (TSetEnvelopeRecipientsRequest['recipients'][number] & {
/**
* Stable client-side key for the signer row, echoed to the server as
* `clientId` so newly created recipients can adopt their server-assigned id.
*/
formId?: string;
})[];
type EnvelopeEditorProviderValue = {
editorConfig: EnvelopeEditorConfig;
@@ -47,8 +58,8 @@ type EnvelopeEditorProviderValue = {
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
setRecipientsDebounced: (recipients: SetRecipientsPayload) => void;
setRecipientsAsync: (recipients: SetRecipientsPayload) => Promise<void>;
getRecipientColorKey: (recipientId: number) => TRecipientColor;
@@ -170,6 +181,41 @@ export const EnvelopeEditorProvider = ({
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
/**
* Server-assigned ids for signers created during this session, keyed by
* their stable formId. The recipients form only learns real ids on a form
* reset (step navigation), so this map bridges the gap between autosaves.
*/
const recipientIdByFormIdRef = useRef<Map<string, number>>(new Map());
/**
* Merges known server-assigned ids into the outgoing payload. Without
* this, a newly created signer (id-less in the form until the next form
* reset) would be deleted and recreated on every autosave — churning ids,
* signing tokens and the audit log. The formId doubles as the `clientId`
* echoed back by the server.
*/
const withKnownRecipientIds = (localRecipients: SetRecipientsPayload) =>
localRecipients.map((recipient) => ({
...recipient,
id: recipient.id ?? (recipient.formId ? recipientIdByFormIdRef.current.get(recipient.formId) : undefined),
clientId: recipient.formId,
}));
/**
* Records the ids of newly created recipients from the save response. A
* ref — rather than writing ids back into the form — is deliberate: the
* response can land mid interaction, and a form write here re-renders the
* signer rows, which breaks an in-progress recipient drag.
*/
const rememberCreatedRecipientIds = (recipients: TSetEnvelopeRecipientsResponse['data']) => {
for (const recipient of recipients) {
if (recipient.clientId) {
recipientIdByFormIdRef.current.set(recipient.clientId, recipient.id);
}
}
};
const registerExternalFlush = useCallback((key: string, flush: () => Promise<void>) => {
externalFlushCallbacksRef.current.set(key, flush);
@@ -210,7 +256,7 @@ export const EnvelopeEditorProvider = ({
triggerSave: setRecipientsDebounced,
flush: flushSetRecipients,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
} = useEnvelopeAutosave(async (localRecipients: SetRecipientsPayload) => {
try {
let recipients: TEditorEnvelope['recipients'] = [];
@@ -220,10 +266,12 @@ export const EnvelopeEditorProvider = ({
const response = await setRecipientsMutation.mutateAsync({
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
recipients: localRecipients,
recipients: withKnownRecipientIds(localRecipients),
});
recipients = response.data;
rememberCreatedRecipientIds(response.data);
} else {
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
}
@@ -252,7 +300,7 @@ export const EnvelopeEditorProvider = ({
}
}, 1000);
const setRecipientsAsync = async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
const setRecipientsAsync = async (localRecipients: SetRecipientsPayload) => {
setRecipientsDebounced(localRecipients);
await flushSetRecipients();
};