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,