chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
+17 -9
View File
@@ -3,7 +3,7 @@ import type { EnvelopeItem } from '@prisma/client';
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
import { downloadFile } from './download-file';
type DocumentVersion = 'original' | 'signed';
type DocumentVersion = 'original' | 'signed' | 'pending';
type DownloadPDFProps = {
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
@@ -14,16 +14,25 @@ type DownloadPDFProps = {
* Specifies which version of the document to download.
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version.
* 'pending': Downloads the original document with currently-inserted fields burned in.
* Only valid while the envelope is in PENDING status. Not supported via
* recipient token.
*/
version?: DocumentVersion;
};
export const downloadPDF = async ({
envelopeItem,
token,
fileName,
version = 'signed',
}: DownloadPDFProps) => {
const versionToFilenameSuffix = (version: DocumentVersion): string => {
switch (version) {
case 'signed':
return '_signed.pdf';
case 'pending':
return '_pending.pdf';
case 'original':
return '.pdf';
}
};
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: envelopeItem,
@@ -34,10 +43,9 @@ export const downloadPDF = async ({
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
downloadFile({
filename: `${baseTitle}${suffix}`,
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
data: blob,
});
};
@@ -1,4 +1,4 @@
export const getBoundingClientRect = (element: HTMLElement) => {
export const getBoundingClientRect = (element: HTMLElement | Element) => {
const rect = element.getBoundingClientRect();
const { width, height } = rect;
@@ -1,7 +1,15 @@
import { posthog } from 'posthog-js';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
let posthogPromise: Promise<typeof import('posthog-js')> | null = null;
const getPosthog = async () => {
if (!posthogPromise) {
posthogPromise = import('posthog-js');
}
return posthogPromise;
};
export function useAnalytics() {
// const featureFlags = useFeatureFlags();
const isPostHogEnabled = extractPostHogConfig();
@@ -17,7 +25,9 @@ export function useAnalytics() {
return;
}
posthog.capture(event, properties);
void getPosthog().then(({ default: posthog }) => {
posthog.capture(event, properties);
});
};
/**
@@ -31,7 +41,9 @@ export function useAnalytics() {
return;
}
posthog.captureException(error, properties);
void getPosthog().then(({ default: posthog }) => {
posthog.captureException(error, properties);
});
};
/**
@@ -5,10 +5,7 @@ type SaveRequest<T, R> = {
onResponse?: (response: R) => void;
};
export const useAutoSave = <T, R = void>(
onSave: (data: T) => Promise<R>,
options: { delay?: number } = {},
) => {
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout>();
@@ -11,8 +11,7 @@ export type UseCopyShareLinkOptions = {
export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions) {
const [, copyToClipboard] = useCopyToClipboard();
const { mutateAsync: createOrGetShareLink, isPending: isCreatingShareLink } =
trpc.document.share.useMutation();
const { mutateAsync: createOrGetShareLink, isPending: isCreatingShareLink } = trpc.document.share.useMutation();
/**
* Copy a newly created, or pre-existing share link to the user's clipboard.
@@ -18,9 +18,7 @@ export function useCopyToClipboard(): [CopiedValue, CopyFn] {
// Try to save to clipboard then save it in the state if worked
try {
isClipboardApiSupported
? await handleClipboardApiCopy(text, blobType)
: await handleWriteTextCopy(text);
isClipboardApiSupported ? await handleClipboardApiCopy(text, blobType) : await handleWriteTextCopy(text);
setCopiedText(await text);
return true;
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useCallback } from 'react';
export const useDocumentElement = () => {
/**
@@ -14,7 +13,8 @@ export const useDocumentElement = () => {
const target = event.target;
const $page =
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector);
target.closest<HTMLElement>(pageSelector) ??
document.elementsFromPoint(event.clientX, event.clientY).find((el) => el.matches(pageSelector));
if (!$page) {
return null;
@@ -28,19 +28,9 @@ export const useDocumentElement = () => {
* as a percentage of the page width and height.
*/
const getFieldPosition = (page: HTMLElement, field: HTMLElement) => {
const {
top: pageTop,
left: pageLeft,
height: pageHeight,
width: pageWidth,
} = getBoundingClientRect(page);
const { top: pageTop, left: pageLeft, height: pageHeight, width: pageWidth } = getBoundingClientRect(page);
const {
top: fieldTop,
left: fieldLeft,
height: fieldHeight,
width: fieldWidth,
} = getBoundingClientRect(field);
const { top: fieldTop, left: fieldLeft, height: fieldHeight, width: fieldWidth } = getBoundingClientRect(field);
return {
x: ((fieldLeft - pageLeft) / pageWidth) * 100,
@@ -57,31 +47,28 @@ export const useDocumentElement = () => {
* @param mouseWidth The artifical width of the mouse.
* @param mouseHeight The artifical height of the mouse.
*/
const isWithinPageBounds = useCallback(
(event: MouseEvent, pageSelector: string, mouseWidth = 0, mouseHeight = 0) => {
const $page = getPage(event, pageSelector);
const isWithinPageBounds = useCallback((event: MouseEvent, pageSelector: string, mouseWidth = 0, mouseHeight = 0) => {
const $page = getPage(event, pageSelector);
if (!$page) {
return false;
}
if (!$page) {
return false;
}
const { top, left, height, width } = $page.getBoundingClientRect();
const { top, left, height, width } = $page.getBoundingClientRect();
const halfMouseWidth = mouseWidth / 2;
const halfMouseHeight = mouseHeight / 2;
const halfMouseWidth = mouseWidth / 2;
const halfMouseHeight = mouseHeight / 2;
if (event.clientY > top + height - halfMouseHeight || event.clientY < top + halfMouseHeight) {
return false;
}
if (event.clientY > top + height - halfMouseHeight || event.clientY < top + halfMouseHeight) {
return false;
}
if (event.clientX > left + width - halfMouseWidth || event.clientX < left + halfMouseWidth) {
return false;
}
if (event.clientX > left + width - halfMouseWidth || event.clientX < left + halfMouseWidth) {
return false;
}
return true;
},
[],
);
return true;
}, []);
return {
getPage,
@@ -1,15 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field, Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import type { TEnvelope } from '../../types/envelope';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created.
@@ -37,7 +35,7 @@ const ZEditorFieldsFormSchema = z.object({
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
type EditorFieldsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
};
@@ -61,16 +59,13 @@ type UseEditorFieldsResponse = {
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
selectedRecipient: Recipient | null;
selectedRecipient: TEditorEnvelope['recipients'][number] | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
};
export const useEditorFields = ({
envelope,
handleFieldsUpdate,
}: EditorFieldsProps): UseEditorFieldsResponse => {
export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsProps): UseEditorFieldsResponse => {
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
@@ -123,9 +118,7 @@ export const useEditorFields = ({
}
const foundField = localFields.find((field) => field.formId === formId);
const recipient = envelope.recipients.find(
(recipient) => recipient.id === foundField?.recipientId,
);
const recipient = envelope.recipients.find((recipient) => recipient.id === foundField?.recipientId);
if (recipient) {
setSelectedRecipient(recipient.id);
@@ -222,14 +215,16 @@ export const useEditorFields = ({
const duplicateFieldToAllPages = useCallback(
(field: TLocalField): TLocalField[] => {
const pages = Array.from(document.querySelectorAll('[data-page-number]'));
const totalPages = getPdfPagesCount();
const newFields: TLocalField[] = [];
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return newFields;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === field.page) {
return;
continue;
}
const newField: TLocalField = {
@@ -241,7 +236,7 @@ export const useEditorFields = ({
append(newField);
newFields.push(newField);
});
}
triggerFieldsUpdate();
return newFields;
@@ -317,9 +312,7 @@ export const useEditorFields = ({
};
};
const restrictFieldPosValues = (
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
) => {
const restrictFieldPosValues = (field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>) => {
return {
positionX: Math.max(0, Math.min(100, field.positionX)),
positionY: Math.max(0, Math.min(100, field.positionY)),
@@ -1,20 +1,14 @@
import { useId } from 'react';
import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zodResolver } from '@hookform/resolvers/zod';
import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useId } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { z } from 'zod';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import type { TEnvelope } from '../../types/envelope';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
@@ -36,12 +30,12 @@ export const ZEditorRecipientsFormSchema = z.object({
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
type EditorRecipientsProps = {
envelope: TEnvelope;
envelope: TEditorEnvelope;
};
type ResetFormOptions = {
recipients?: Recipient[];
documentMeta?: TEnvelope['documentMeta'];
recipients?: TEditorEnvelope['recipients'];
documentMeta?: TEditorEnvelope['documentMeta'];
};
type UseEditorRecipientsResponse = {
@@ -49,9 +43,7 @@ type UseEditorRecipientsResponse = {
resetForm: (options?: ResetFormOptions) => void;
};
export const useEditorRecipients = ({
envelope,
}: EditorRecipientsProps): UseEditorRecipientsResponse => {
export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEditorRecipientsResponse => {
const initialId = useId();
const generateDefaultValues = (options?: ResetFormOptions) => {
@@ -84,8 +76,7 @@ export const useEditorRecipients = ({
return {
signers,
signingOrder: documentMeta?.signingOrder ?? envelope.documentMeta.signingOrder,
allowDictateNextSigner:
documentMeta?.allowDictateNextSigner ?? envelope.documentMeta.allowDictateNextSigner,
allowDictateNextSigner: documentMeta?.allowDictateNextSigner ?? envelope.documentMeta.allowDictateNextSigner,
};
};
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useCallback, useEffect, useState } from 'react';
export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => {
const [bounds, setBounds] = useState({
@@ -17,7 +16,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
: elementOrSelector;
if (!$el) {
throw new Error('Element not found');
return { top: 0, left: 0, width: 0, height: 0 };
}
if (withScroll) {
@@ -36,7 +35,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
useEffect(() => {
setBounds(calculateBounds());
}, []);
}, [calculateBounds]);
useEffect(() => {
const onResize = () => {
@@ -48,7 +47,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
}, [calculateBounds]);
useEffect(() => {
const $el =
@@ -69,7 +68,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
observer.disconnect();
};
}, []);
}, [elementOrSelector, calculateBounds]);
return bounds;
};
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { RefObject, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.
@@ -15,7 +15,9 @@ export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay
// A debounce or promise means something is pending
setIsPending(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
timeoutRef.current = setTimeout(async () => {
@@ -1,13 +1,9 @@
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_CONTENT_SELECTOR, PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { Field } from '@prisma/client';
import { useCallback, useEffect, useState } from 'react';
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: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>,
) => {
export const useFieldPageCoords = (field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
@@ -16,9 +12,7 @@ export const useFieldPageCoords = (
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const $page = document.querySelector<HTMLElement>(`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`);
if (!$page) {
return;
@@ -57,23 +51,65 @@ export const useFieldPageCoords = (
};
}, [calculateCoords]);
// Watch for the page element to appear in the DOM (e.g. after a virtual list
// scroll) and recalculate coords. Also attach a ResizeObserver once the page
// element exists.
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const pageSelector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`;
if (!$page) {
return;
let resizeObserver: ResizeObserver | null = null;
let observedElement: HTMLElement | null = null;
const attachResizeObserver = ($page: HTMLElement) => {
if ($page === observedElement) {
return;
}
resizeObserver?.disconnect();
resizeObserver = new ResizeObserver(() => {
calculateCoords();
});
resizeObserver.observe($page);
observedElement = $page;
};
// Try to attach immediately if the page already exists.
const existingPage = document.querySelector<HTMLElement>(pageSelector);
if (existingPage) {
attachResizeObserver(existingPage);
}
const observer = new ResizeObserver(() => {
// Watch for DOM mutations to detect when the page element appears (e.g.
// after the virtual list scrolls to a new page and renders it).
// Scope to the PDF viewer content container to avoid firing on unrelated
// DOM changes elsewhere in the document.
const mutationObserver = new MutationObserver(() => {
const $page = document.querySelector<HTMLElement>(pageSelector);
if (!$page) {
return;
}
if ($page === observedElement) {
return;
}
calculateCoords();
attachResizeObserver($page);
});
observer.observe($page);
const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
mutationObserver.observe($container, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
mutationObserver.disconnect();
resizeObserver?.disconnect();
observedElement = null;
};
}, [calculateCoords, field.page]);
@@ -0,0 +1,35 @@
import { PDF_VIEWER_CONTENT_SELECTOR, PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { useEffect, useState } from 'react';
/**
* Returns whether the PDF page element for the given page number is currently
* present in the DOM. With virtual list rendering only pages near the viewport
* are mounted, so this hook lets consumers skip rendering when their page is
* virtualised away.
*/
export const useIsPageInDom = (pageNumber: number) => {
const [isPageInDom, setIsPageInDom] = useState(false);
useEffect(() => {
const selector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pageNumber}"]`;
setIsPageInDom(document.querySelector(selector) !== null);
const observer = new MutationObserver(() => {
setIsPageInDom(document.querySelector(selector) !== null);
});
const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body;
observer.observe($container, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [pageNumber]);
return isPageInDom;
};
@@ -1,135 +1,85 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { useEffect, useMemo, useRef } from 'react';
import type { PageRenderData } from '../providers/envelope-render-provider';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) {
const pageContext = usePageContext();
export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => {
const { pageWidth, pageHeight, scale, imageLoadingState, pageNumber } = pageData;
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
() => ({
scale: 1,
width: pageWidth,
height: pageHeight,
}),
[pageWidth, pageHeight],
);
/**
* The viewport scaled according to page width.
*/
const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
() => ({
scale,
width: pageWidth * scale,
height: pageHeight * scale,
}),
[pageWidth, pageHeight, scale],
);
/**
* Viewport with the device pixel ratio applied so we can render the PDF
* in a higher resolution.
*/
const renderViewport = useMemo(
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
[page, rotate, scale],
);
useEffect(() => {
const { current: container } = konvaContainer;
/**
* Render the PDF and create the scaled Konva stage.
*/
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
if (!container || imageLoadingState !== 'loaded') {
return;
}
const { current: canvas } = canvasElement;
const { current: kContainer } = konvaContainer;
stage.current = new Konva.Stage({
container,
id: `page-${pageNumber}`,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
if (!canvas || !kContainer) {
return;
}
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
canvas.width = renderViewport.width;
canvas.height = renderViewport.height;
stage.current.add(pageLayer.current);
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
const renderContext: RenderParameters = {
canvas,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: renderViewport,
};
void document.fonts.ready.then(() => {
pageLayer.current?.batchDraw();
});
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
stage.current = new Konva.Stage({
container: kContainer,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
);
return () => {
stage.current?.destroy();
stage.current = null;
};
}, [imageLoadingState, scaledViewport]);
return {
canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
pageContext,
renderError,
setRenderError,
};
}
};
@@ -1,5 +1,5 @@
import * as React from 'react';
import type { Dispatch, SetStateAction } from 'react';
import * as React from 'react';
function dispatchStorageEvent(key: string, newValue: string | null) {
window.dispatchEvent(new StorageEvent('storage', { key, newValue }));
@@ -25,20 +25,13 @@ const useSessionStorageSubscribe = (callback: (event: StorageEvent) => void) =>
return () => window.removeEventListener('storage', callback);
};
export function useSessionStorage<T>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>] {
export function useSessionStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {
const serializedInitialValue = JSON.stringify(initialValue);
const getSnapshot = () => getSessionStorageItem(key);
const getServerSnapshot = () => serializedInitialValue;
const store = React.useSyncExternalStore(
useSessionStorageSubscribe,
getSnapshot,
getServerSnapshot,
);
const store = React.useSyncExternalStore(useSessionStorageSubscribe, getSnapshot, getServerSnapshot);
const setState: Dispatch<SetStateAction<T>> = React.useCallback(
(v) => {
@@ -1,51 +1,43 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import {
DEFAULT_EDITOR_CONFIG,
type EnvelopeEditorConfig,
type TEditorEnvelope,
} 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 { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TEnvelope } from '../../types/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEditorFields } from '../hooks/use-editor-fields';
import { useEditorRecipients } from '../hooks/use-editor-recipients';
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],
);
};
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
type EnvelopeEditorProviderValue = {
envelope: TEnvelope;
editorConfig: EnvelopeEditorConfig;
envelope: TEditorEnvelope;
isEmbedded: boolean;
isDocument: boolean;
isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
@@ -57,8 +49,9 @@ type EnvelopeEditorProviderValue = {
editorRecipients: ReturnType<typeof useEditorRecipients>;
isAutosaving: boolean;
flushAutosave: () => Promise<void>;
flushAutosave: () => Promise<TEditorEnvelope>;
autosaveError: boolean;
resetForms: () => void;
relativePath: {
basePath: string;
@@ -68,12 +61,20 @@ type EnvelopeEditorProviderValue = {
templateRootPath: string;
};
navigateToStep: (step: EnvelopeEditorStep) => void;
syncEnvelope: () => Promise<void>;
registerExternalFlush: (key: string, flush: () => Promise<void>) => () => void;
registerPendingMutation: (promise: Promise<unknown>) => void;
organisationEmails?: { id: string; email: string }[];
};
interface EnvelopeEditorProviderProps {
children: React.ReactNode;
initialEnvelope: TEnvelope;
editorConfig?: EnvelopeEditorConfig;
initialEnvelope: TEditorEnvelope;
organisationEmails?: { id: string; email: string }[];
}
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
@@ -90,14 +91,49 @@ export const useCurrentEnvelopeEditor = () => {
export const EnvelopeEditorProvider = ({
children,
editorConfig = DEFAULT_EDITOR_CONFIG,
initialEnvelope,
organisationEmails,
}: EnvelopeEditorProviderProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope);
const [_searchParams, setSearchParams] = useSearchParams();
const [envelope, _setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
const registerExternalFlush = useCallback((key: string, flush: () => Promise<void>) => {
externalFlushCallbacksRef.current.set(key, flush);
return () => {
externalFlushCallbacksRef.current.delete(key);
};
}, []);
const registerPendingMutation = useCallback((promise: Promise<unknown>) => {
pendingMutationsRef.current.add(promise);
void promise.finally(() => {
pendingMutationsRef.current.delete(promise);
});
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embedded !== undefined;
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
@@ -107,23 +143,48 @@ export const EnvelopeEditorProvider = ({
envelope,
});
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
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,
},
});
const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
const setFieldsMutation = trpc.envelope.field.set.useMutation();
const updateEnvelopeMutation = trpc.envelope.update.useMutation();
/**
* Handles debouncing the recipients updates to the server.
*
* Will set the local envelope recipients and fields after the update is complete.
*/
const {
triggerSave: setRecipientsDebounced,
flush: flushSetRecipients,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
try {
let recipients: TEditorEnvelope['recipients'] = [];
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients: localRecipients,
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
}
setEnvelope((prev) => ({
...prev,
recipients,
fields: prev.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
);
setAutosaveError(false);
},
onError: (err) => {
} catch (err) {
console.error(err);
setAutosaveError(true);
@@ -134,19 +195,57 @@ export const EnvelopeEditorProvider = ({
variant: 'destructive',
duration: 7500,
});
},
});
}
}, 1000);
const setRecipientsAsync = async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
setRecipientsDebounced(localRecipients);
await flushSetRecipients();
};
/**
* Handles debouncing the fields updates to the server.
*
* Will set the local envelope fields after the update is complete.
*/
const {
triggerSave: setFieldsDebounced,
flush: flushSetFields,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope, localFields });
}
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
onSuccess: ({ data: fields }) => {
setEnvelope((prev) => ({
...prev,
fields,
}));
setAutosaveError(false);
},
onError: (err) => {
// Insert the IDs into the local fields.
fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
} catch (err) {
console.error(err);
setAutosaveError(true);
@@ -157,91 +256,74 @@ export const EnvelopeEditorProvider = ({
variant: 'destructive',
duration: 7500,
});
},
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ data: recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
fields: prev.fields.filter((field) =>
recipients.some((recipient) => recipient.id === field.recipientId),
),
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(
envelope.fields.filter((field) =>
recipients.some((recipient) => recipient.id === field.recipientId),
),
);
setAutosaveError(false);
},
onError: (err) => {
console.error(err);
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 (localFields: TLocalField[]) => {
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields: localFields,
});
// Insert the IDs into the local fields.
envelopeFields.data.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
}
}, 2000);
const setFieldsAsync = async (localFields: TLocalField[]) => {
setFieldsDebounced(localFields);
await flushSetFields();
};
/**
* Handles debouncing the envelope updates to the server.
*
* Will set the local envelope after the update is complete.
*/
const {
triggerSave: setEnvelopeDebounced,
flush: setEnvelopeAsync,
triggerSave: updateEnvelopeDebounced,
flush: flushUpdateEnvelope,
isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
data: envelopeUpdates.data,
meta: envelopeUpdates.meta,
});
} = useEnvelopeAutosave(async ({ data, meta }: UpdateEnvelopePayload) => {
try {
const response = !isEmbedded
? await updateEnvelopeMutation.mutateAsync({
envelopeId: envelope.id,
data,
meta,
})
: {};
setEnvelope((prev) => ({
...prev,
...data,
authOptions: {
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
},
...response,
documentMeta: {
...prev.documentMeta,
...meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (meta?.emailSettings || null) as unknown as TDocumentEmailSettings | null,
},
}));
setAutosaveError(false);
} catch (err) {
console.error(err);
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,
});
}
}, 1000);
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
updateEnvelopeDebounced(envelopeUpdates);
await flushUpdateEnvelope();
};
/**
* Updates the local envelope and debounces the update to the server.
*
* Use this when you want to update the local envelope immediately while debouncing
* the actual update to the server.
*/
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
setEnvelope((prev) => ({
@@ -253,35 +335,22 @@ export const EnvelopeEditorProvider = ({
},
}));
setEnvelopeDebounced(envelopeUpdates);
};
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
...envelopeUpdates,
});
updateEnvelopeDebounced(envelopeUpdates);
};
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId,
);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
(recipientId: number) => getRecipientColor(envelope.recipients.findIndex((r) => r.id === recipientId)),
[envelope.recipients],
);
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
const { refetch: reloadEnvelope } = trpc.envelope.editor.get.useQuery(
{
envelopeId: envelope.id,
},
{
initialData: envelope,
enabled: !isEmbedded,
gcTime: 0,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@@ -293,6 +362,11 @@ export const EnvelopeEditorProvider = ({
const syncEnvelope = async () => {
await flushAutosave();
// Bypass syncing for embedded mode.
if (isEmbedded) {
return;
}
const fetchedEnvelopeData = await reloadEnvelope();
if (fetchedEnvelopeData.data) {
@@ -302,55 +376,95 @@ export const EnvelopeEditorProvider = ({
recipients: fetchedEnvelopeData.data.recipients,
documentMeta: fetchedEnvelopeData.data.documentMeta,
});
editorFields.resetForm(fetchedEnvelopeData.data.fields);
}
};
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
const setLocalEnvelope = (localEnvelope: Partial<TEditorEnvelope>) => {
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,
]);
return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
}, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
const relativePath = useMemo(() => {
const documentRootPath = formatDocumentsPath(envelope.team.url);
const templateRootPath = formatTemplatesPath(envelope.team.url);
let documentRootPath = formatDocumentsPath(envelope.team.url);
let templateRootPath = formatTemplatesPath(envelope.team.url);
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
let envelopePath = `${basePath}/${envelope.id}`;
let editorPath = `${basePath}/${envelope.id}/edit`;
if (editorConfig.embedded) {
let embeddedEditorPath =
editorConfig.embedded.mode === 'edit'
? `/embed/v2/authoring/envelope/edit/${envelope.id}`
: `/embed/v2/authoring/envelope/create`;
embeddedEditorPath += `?token=${editorConfig.embedded.presignToken}`;
envelopePath = embeddedEditorPath;
editorPath = embeddedEditorPath;
documentRootPath = embeddedEditorPath;
templateRootPath = embeddedEditorPath;
}
return {
basePath,
envelopePath: `${basePath}/${envelope.id}`,
editorPath: `${basePath}/${envelope.id}/edit`,
envelopePath,
editorPath,
documentRootPath,
templateRootPath,
};
}, [envelope.type, envelope.id]);
const flushAutosave = async (): Promise<void> => {
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
const navigateToStep = (step: EnvelopeEditorStep) => {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
if (step === 'upload') {
newParams.delete('step');
} else {
newParams.set('step', step);
}
return newParams;
});
};
const resetForms = () => {
editorRecipients.resetForm({
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
});
editorFields.resetForm(envelopeRef.current.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
// Flush all registered external flushes (e.g., upload page's debounced item updates).
const externalFlushes = Array.from(externalFlushCallbacksRef.current.values());
await Promise.all(externalFlushes.map(async (flush) => flush()));
// Await all registered pending mutations (e.g., in-flight creates/deletes).
// Use allSettled so a single failed mutation doesn't prevent awaiting the rest.
if (pendingMutationsRef.current.size > 0) {
await Promise.allSettled(Array.from(pendingMutationsRef.current));
}
return envelopeRef.current;
};
return (
<EnvelopeEditorContext.Provider
value={{
editorConfig,
envelope,
isEmbedded,
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope,
@@ -366,9 +480,110 @@ export const EnvelopeEditorProvider = ({
isAutosaving,
relativePath,
syncEnvelope,
navigateToStep,
resetForms,
registerExternalFlush,
registerPendingMutation,
organisationEmails,
}}
>
{children}
</EnvelopeEditorContext.Provider>
);
};
type MapLocalRecipientsToRecipientsOptions = {
envelope: TEditorEnvelope;
localRecipients: TSetEnvelopeRecipientsRequest['recipients'];
};
const mapLocalRecipientsToRecipients = ({
envelope,
localRecipients,
}: MapLocalRecipientsToRecipientsOptions): TEditorEnvelope['recipients'] => {
let smallestRecipientId = localRecipients.reduce((min, recipient) => {
if (recipient.id && recipient.id < min) {
return recipient.id;
}
return min;
}, -1);
return localRecipients.map((recipient) => {
const foundRecipient = envelope.recipients.find((r) => r.id === recipient.id);
let recipientId = recipient.id;
if (recipientId === undefined) {
recipientId = smallestRecipientId;
smallestRecipientId--;
}
return {
id: recipientId,
envelopeId: envelope.id,
email: recipient.email,
name: recipient.name,
token: foundRecipient?.token || '',
documentDeletedAt: foundRecipient?.documentDeletedAt || null,
expired: foundRecipient?.expired || null,
signedAt: foundRecipient?.signedAt || null,
authOptions: recipient.actionAuth.length > 0 ? { actionAuth: recipient.actionAuth, accessAuth: [] } : null,
signingOrder: recipient.signingOrder ?? null,
rejectionReason: foundRecipient?.rejectionReason || null,
role: recipient.role,
readStatus: foundRecipient?.readStatus || ReadStatus.NOT_OPENED,
signingStatus: foundRecipient?.signingStatus || SigningStatus.NOT_SIGNED,
sendStatus: foundRecipient?.sendStatus || SendStatus.NOT_SENT,
expiresAt: foundRecipient?.expiresAt || null,
expirationNotifiedAt: foundRecipient?.expirationNotifiedAt || null,
};
});
};
type MapLocalFieldsToFieldsOptions = {
localFields: TLocalField[];
envelope: TEditorEnvelope;
};
const mapLocalFieldsToFields = ({
envelope,
localFields,
}: MapLocalFieldsToFieldsOptions): TSetEnvelopeFieldsResponse['data'] => {
let smallestFieldId = localFields.reduce((min, field) => {
if (field.id && field.id < min) {
return field.id;
}
return min;
}, -1);
return localFields.map((field) => {
const foundField = envelope.fields.find((envelopeField) => envelopeField.id === field.id);
let fieldId = field.id;
if (fieldId === undefined) {
fieldId = smallestFieldId;
smallestFieldId--;
}
return {
...field,
formId: field.formId,
id: fieldId,
envelopeId: envelope.id,
envelopeItemId: field.envelopeItemId,
type: field.type,
recipientId: field.recipientId,
positionX: new Prisma.Decimal(field.positionX),
positionY: new Prisma.Decimal(field.positionY),
width: new Prisma.Decimal(field.width),
height: new Prisma.Decimal(field.height),
secondaryId: foundField?.secondaryId || '',
inserted: foundField?.inserted || false,
customText: foundField?.customText || '',
fieldMeta: field.fieldMeta || null,
};
});
};
@@ -1,23 +1,36 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { Field, Recipient } from '@prisma/client';
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { Field, Recipient } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
type FileData =
| {
status: 'loading' | 'error';
}
| {
file: Uint8Array;
status: 'loaded';
};
/**
* The signature data for an inserted signature field.
*
* Loaded separately from the envelope to avoid bloating the envelope.get response
* with potentially large base64 image payloads.
*/
export type EnvelopeRenderFieldSignature = {
fieldId: number;
signatureImageAsBase64: string | null;
typedSignature: string | null;
};
export type PageRenderData = {
scale: number;
pageIndex: number;
pageNumber: number;
pageWidth: number;
pageHeight: number;
imageLoadingState: ImageLoadingState;
};
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
@@ -25,16 +38,29 @@ type EnvelopeRenderOverrideSettings = {
showRecipientSigningStatus?: boolean;
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderItem = {
id: string;
title: string;
order: number;
envelopeId: string;
/**
* The PDF data to render.
*
* If it's a string we assume it's a URL to the PDF file.
*/
data: Uint8Array | string;
};
type EnvelopeRenderProviderValue = {
getPdfBuffer: (envelopeItemId: string) => FileData | null;
version: DocumentDataVersion;
envelopeItems: EnvelopeRenderItem[];
envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type'];
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: Field[];
signatures: EnvelopeRenderFieldSignature[];
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
@@ -46,7 +72,26 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
/**
* The envelope item version to render.
*/
version: DocumentDataVersion;
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'>;
/**
* The envelope items to render.
*
* If data is optional then we build the URL based of the IDs.
*/
envelopeItems: {
id: string;
title: string;
order: number;
envelopeId: string;
documentDataId: string;
data?: Uint8Array | string;
}[];
/**
* Optional fields which are passed down to renderers for custom rendering needs.
@@ -55,6 +100,15 @@ interface EnvelopeRenderProviderProps {
*/
fields?: Field[];
/**
* Optional inserted signature data for signature fields.
*
* Fetched separately from the envelope to keep the envelope response lean.
* If a signature field has no entry here, the renderer will fall back to
* showing the field type placeholder.
*/
signatures?: EnvelopeRenderFieldSignature[];
/**
* Optional recipient used to determine the color of the fields and hover
* previews.
@@ -70,6 +124,13 @@ interface EnvelopeRenderProviderProps {
*/
token: string | undefined;
/**
* The presign token to access the envelope.
*
* If not provided, it will be assumed that the current user can access the document.
*/
presignToken?: string | undefined;
/**
* Custom override settings for generic page renderers.
*/
@@ -89,89 +150,62 @@ export const useCurrentEnvelopeRender = () => {
};
/**
* Manages fetching and storing PDF files to render on the client.
* Manages fetching the data required to render an envelope and it's items.
*/
export const EnvelopeRenderProvider = ({
children,
envelope,
envelopeItems: envelopeItemsFromProps,
fields,
signatures,
token,
presignToken,
recipients = [],
version,
overrideSettings,
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
() =>
[...envelopeItemsFromProps]
.sort((a, b) => a.order - b.order)
.map((item) => {
const pdfUrl = getDocumentDataUrl({
envelopeId: envelope.id,
envelopeItemId: item.id,
documentDataId: item.documentDataId,
version,
token,
presignToken,
});
const data = item.data || pdfUrl;
return {
...item,
data,
};
}),
[envelopeItemsFromProps, envelope.id, token, version, presignToken],
);
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
if (files[envelopeItem.id]?.status === 'loading') {
return;
}
const [currentItemId, setCurrentItemId] = useState<string | null>(envelopeItems[0]?.id ?? null);
if (!files[envelopeItem.id]) {
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
status: 'loading',
},
}));
}
try {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'view',
envelopeItem: envelopeItem,
token,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const file = await blob.arrayBuffer();
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
file: new Uint8Array(file),
status: 'loaded',
},
}));
} catch (error) {
console.error(error);
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
status: 'error',
},
}));
}
};
const getPdfBuffer = useCallback(
(envelopeItemId: string) => {
return files[envelopeItemId] || null;
},
[files],
);
const currentItem = useMemo((): EnvelopeRenderItem | null => {
return envelopeItems.find((item) => item.id === currentItemId) ?? null;
}, [currentItemId, envelopeItems]);
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
const foundItem = envelopeItems.find((item) => item.id === envelopeItemId);
setCurrentItem(foundItem ?? null);
setCurrentItemId(foundItem?.id ?? null);
};
// Set the selected item to the first item if none is set.
useEffect(() => {
if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) {
setCurrentItem(null);
setCurrentItemId(null);
}
if (!currentItem && envelopeItems.length > 0) {
@@ -179,41 +213,24 @@ export const EnvelopeRenderProvider = ({
}
}, [currentItem, envelopeItems]);
// Look for any missing pdf files and load them.
useEffect(() => {
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
for (const item of missingFiles) {
void loadEnvelopeItemPdfFile(item);
}
}, [envelope.envelopeItems]);
const recipientIds = useMemo(
() => recipients.map((recipient) => recipient.id).sort(),
[recipients],
);
const recipientIds = useMemo(() => recipients.map((recipient) => recipient.id).sort(), [recipients]);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
(recipientId: number) => getRecipientColor(recipientIds.findIndex((id) => id === recipientId)),
[recipientIds],
);
return (
<EnvelopeRenderContext.Provider
value={{
getPdfBuffer,
version,
envelopeItems,
envelopeStatus: envelope.status,
envelopeType: envelope.type,
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
signatures: signatures ?? [],
recipients,
getRecipientColorKey,
renderError,
@@ -1,11 +1,7 @@
import type { I18n, Messages } from '@lingui/core';
import { setupI18n } from '@lingui/core';
import {
APP_I18N_OPTIONS,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '../../constants/i18n';
import { APP_I18N_OPTIONS, isValidLanguageCode, SUPPORTED_LANGUAGE_CODES } from '../../constants/i18n';
import { env } from '../../utils/env';
import { remember } from '../../utils/remember';
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { type Messages, setupI18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { useState } from 'react';
import type { I18nLocaleData } from '../../constants/i18n';
@@ -1,7 +1,6 @@
import { createContext, useContext } from 'react';
import React from 'react';
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import type React from 'react';
import { createContext, useContext } from 'react';
type OrganisationProviderValue = OrganisationSession;
@@ -27,7 +26,5 @@ export const useOptionalCurrentOrganisation = () => {
};
export const OrganisationProvider = ({ children, organisation }: OrganisationProviderProps) => {
return (
<OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>
);
return <OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>;
};
+21 -7
View File
@@ -1,13 +1,11 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import React from 'react';
import type { Session } from '@prisma/client';
import { useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { trpc } from '@documenso/trpc/client';
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import type { Session } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useLocation } from 'react-router';
import { SKIP_QUERY_BATCH_META } from '../../constants/trpc';
@@ -71,11 +69,27 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
const organisations = await trpc.organisation.internal.getOrganisationSession
.query(undefined, SKIP_QUERY_BATCH_META.trpc)
.catch(() => {
.catch((e) => {
const errorMessage = typeof e.message === 'string' ? e.message.toLowerCase() : '';
const isNetworkError = errorMessage.includes('networkerror') || errorMessage.includes('failed to fetch');
// If the error is a transient network/abort error (e.g. page refresh while
// fetch was in-flight), return null to signal we should skip the state update.
if (isNetworkError) {
return null;
}
// Todo: (RR7) Log
return [];
});
// Skip session update if the organisation fetch was aborted due to a transient
// network error (e.g. page refresh while fetch was in-flight).
if (organisations === null) {
return;
}
setSession({
session: newSession.session,
user: newSession.user,
+6 -13
View File
@@ -1,11 +1,7 @@
import type { Recipient } from '@prisma/client';
import {
DocumentDistributionMethod,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { DocumentDistributionMethod, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
type RecipientForType = Pick<Recipient, 'role' | 'signingStatus' | 'readStatus' | 'sendStatus'>;
export enum RecipientStatusType {
COMPLETED = 'completed',
@@ -16,7 +12,7 @@ export enum RecipientStatusType {
}
export const getRecipientType = (
recipient: Recipient,
recipient: RecipientForType,
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
) => {
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
@@ -27,10 +23,7 @@ export const getRecipientType = (
return RecipientStatusType.REJECTED;
}
if (
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
if (recipient.readStatus === ReadStatus.OPENED && recipient.signingStatus === SigningStatus.NOT_SIGNED) {
return RecipientStatusType.OPENED;
}
@@ -45,7 +38,7 @@ export const getRecipientType = (
return RecipientStatusType.UNSIGNED;
};
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {