mirror of
https://github.com/documenso/documenso.git
synced 2026-08-27 00:32:29 +10:00
fix: wip
This commit is contained in:
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user