mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
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:
@ -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}`,
|
||||
);
|
||||
|
||||
281
packages/lib/client-only/hooks/use-editor-fields.ts
Normal file
281
packages/lib/client-only/hooks/use-editor-fields.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
90
packages/lib/client-only/hooks/use-envelope-autosave.ts
Normal file
90
packages/lib/client-only/hooks/use-envelope-autosave.ts
Normal 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 };
|
||||
}
|
||||
@ -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,
|
||||
|
||||
286
packages/lib/client-only/providers/envelope-editor-provider.tsx
Normal file
286
packages/lib/client-only/providers/envelope-editor-provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
148
packages/lib/client-only/providers/envelope-render-provider.tsx
Normal file
148
packages/lib/client-only/providers/envelope-render-provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user