fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-10-20 14:54:42 +00:00
448 changed files with 33524 additions and 9229 deletions

View File

@ -1,5 +1,5 @@
import { trpc } from '@documenso/trpc/react';
import type { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
import type { TShareDocumentRequest } from '@documenso/trpc/server/document-router/share-document.types';
import { useCopyToClipboard } from './use-copy-to-clipboard';
@ -12,14 +12,14 @@ export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions
const [, copyToClipboard] = useCopyToClipboard();
const { mutateAsync: createOrGetShareLink, isPending: isCreatingShareLink } =
trpc.shareLink.createOrGetShareLink.useMutation();
trpc.document.share.useMutation();
/**
* Copy a newly created, or pre-existing share link to the user's clipboard.
*
* @param payload The payload to create or get a share link.
*/
const createAndCopyShareLink = async (payload: TCreateOrGetShareLinkMutationSchema) => {
const createAndCopyShareLink = async (payload: TShareDocumentRequest) => {
const valueToCopy = createOrGetShareLink(payload).then(
(result) => `${window.location.origin}/share/${result.slug}`,
);

View File

@ -0,0 +1,281 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import type { TEnvelope } from '../../types/envelope';
export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created.
id: z.number().optional(),
// This is the local client side ID of the field.
formId: z.string().min(1),
// This is the ID of the envelope item to put the field on.
envelopeItemId: z.string(),
type: z.nativeEnum(FieldType),
recipientId: z.number(),
page: z.number().min(1),
positionX: z.number().min(0),
positionY: z.number().min(0),
width: z.number().min(0),
height: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
});
export type TLocalField = z.infer<typeof ZLocalFieldSchema>;
const ZEditorFieldsFormSchema = z.object({
fields: z.array(ZLocalFieldSchema),
});
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
type EditorFieldsProps = {
envelope: TEnvelope;
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
};
type UseEditorFieldsResponse = {
localFields: TLocalField[];
// Selected field
selectedField: TLocalField | undefined;
setSelectedField: (formId: string | null) => void;
// Field operations
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
removeFieldsByFormId: (formIds: string[]) => void;
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
duplicateFieldToAllPages: (field: TLocalField, recipientId?: number) => TLocalField[];
// Field utilities
getFieldByFormId: (formId: string) => TLocalField | undefined;
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
selectedRecipient: Recipient | null;
setSelectedRecipient: (recipientId: number | null) => void;
};
export const useEditorFields = ({
envelope,
handleFieldsUpdate,
}: EditorFieldsProps): UseEditorFieldsResponse => {
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const form = useForm<TEditorFieldsFormSchema>({
defaultValues: {
fields: envelope.fields.map(
(field): TLocalField => ({
id: field.id,
formId: nanoid(),
envelopeItemId: field.envelopeItemId,
page: field.page,
type: field.type,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
}),
),
},
resolver: zodResolver(ZEditorFieldsFormSchema),
});
const {
append,
remove,
update,
fields: localFields,
} = useFieldArray({
control: form.control,
name: 'fields',
keyName: 'react-hook-form-id',
});
const triggerFieldsUpdate = () => {
void handleFieldsUpdate(form.getValues().fields);
};
const setSelectedField = (formId: string | null, bypassCheck = false) => {
if (!formId) {
setSelectedFieldFormId(null);
return;
}
const foundField = localFields.find((field) => field.formId === formId);
const recipient = envelope.recipients.find(
(recipient) => recipient.id === foundField?.recipientId,
);
if (recipient) {
setSelectedRecipient(recipient.id);
}
if (bypassCheck) {
console.log(3);
setSelectedFieldFormId(formId);
return;
}
setSelectedFieldFormId(foundField?.formId ?? null);
};
const addField = useCallback(
(fieldData: Omit<TLocalField, 'formId'>): TLocalField => {
const field: TLocalField = {
...fieldData,
formId: nanoid(12),
};
append(field);
triggerFieldsUpdate();
setSelectedField(field.formId, true);
return field;
},
[append, triggerFieldsUpdate, setSelectedField],
);
const removeFieldsByFormId = useCallback(
(formIds: string[]) => {
const indexes = formIds
.map((formId) => localFields.findIndex((field) => field.formId === formId))
.filter((index) => index !== -1);
if (indexes.length > 0) {
remove(indexes);
triggerFieldsUpdate();
}
},
[localFields, remove, triggerFieldsUpdate],
);
const updateFieldByFormId = useCallback(
(formId: string, updates: Partial<TLocalField>) => {
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, { ...localFields[index], ...updates });
triggerFieldsUpdate();
}
},
[localFields, update, triggerFieldsUpdate],
);
const duplicateField = useCallback(
(field: TLocalField): TLocalField => {
const newField: TLocalField = {
...structuredClone(field),
id: undefined,
formId: nanoid(12),
recipientId: field.recipientId,
positionX: field.positionX + 3,
positionY: field.positionY + 3,
};
append(newField);
triggerFieldsUpdate();
return newField;
},
[append, triggerFieldsUpdate],
);
const duplicateFieldToAllPages = useCallback(
(field: TLocalField): TLocalField[] => {
const pages = Array.from(document.querySelectorAll('[data-page-number]'));
const newFields: TLocalField[] = [];
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === field.page) {
return;
}
const newField: TLocalField = {
...structuredClone(field),
id: undefined,
formId: nanoid(12),
page: pageNumber,
};
append(newField);
newFields.push(newField);
});
triggerFieldsUpdate();
return newFields;
},
[append, triggerFieldsUpdate],
);
const getFieldByFormId = useCallback(
(formId: string): TLocalField | undefined => {
return localFields.find((field) => field.formId === formId) as TLocalField | undefined;
},
[localFields],
);
const getFieldsByRecipient = useCallback(
(recipientId: number): TLocalField[] => {
return localFields.filter((field) => field.recipientId === recipientId);
},
[localFields],
);
const selectedRecipient = useMemo(() => {
return envelope.recipients.find((recipient) => recipient.id === selectedRecipientId) || null;
}, [selectedRecipientId, envelope.recipients]);
const selectedField = useMemo(() => {
return localFields.find((field) => field.formId === selectedFieldFormId);
}, [selectedFieldFormId, localFields]);
/**
* Keep the selected field form ID in sync with the local fields.
*/
useEffect(() => {
const foundField = localFields.find((field) => field.formId === selectedFieldFormId);
setSelectedFieldFormId(foundField?.formId ?? null);
}, [selectedFieldFormId, localFields]);
const setSelectedRecipient = (recipientId: number | null) => {
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
setSelectedRecipientId(foundRecipient?.id ?? null);
};
return {
// Core state
localFields,
// Field operations
addField,
removeFieldsByFormId,
updateFieldByFormId,
duplicateField,
duplicateFieldToAllPages,
// Field utilities
getFieldByFormId,
getFieldsByRecipient,
// Selected field
selectedField,
setSelectedField,
// Selected recipient
selectedRecipient,
setSelectedRecipient,
};
};

View File

@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastArgsRef = useRef<T | null>(null);
const pendingPromiseRef = useRef<Promise<void> | null>(null);
const [isPending, setIsPending] = useState(false);
const [isCommiting, setIsCommiting] = useState(false);
const triggerSave = useCallback(
(data: T) => {
lastArgsRef.current = data;
// A debounce or promise means something is pending
setIsPending(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
// eslint-disable-next-line @typescript-eslint/no-misused-promises
timeoutRef.current = setTimeout(async () => {
if (!lastArgsRef.current) {
return;
}
const args = lastArgsRef.current;
lastArgsRef.current = null;
timeoutRef.current = null;
setIsCommiting(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}, delay);
},
[saveFn, delay],
);
const flush = useCallback(async () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (pendingPromiseRef.current) {
// Already running → wait for it
await pendingPromiseRef.current;
return;
}
if (lastArgsRef.current) {
const args = lastArgsRef.current;
lastArgsRef.current = null;
setIsCommiting(true);
setIsPending(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}
}, [saveFn]);
useEffect(() => {
const handleBeforeUnload = () => {
if (timeoutRef.current || pendingPromiseRef.current) {
void flush();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [flush]);
return { triggerSave, flush, isPending, isCommiting };
}

View File

@ -5,7 +5,9 @@ import type { Field } from '@prisma/client';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
export const useFieldPageCoords = (field: Field) => {
export const useFieldPageCoords = (
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>,
) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,

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>
);
};