fix: various envelope updates

This commit is contained in:
David Nguyen
2025-11-04 14:57:42 +11:00
parent c89ca83f44
commit 8663c8f883
31 changed files with 400 additions and 144 deletions

View File

@ -152,6 +152,18 @@ export const EditorFieldTextForm = ({
className="h-auto"
placeholder={t`Add text to the field`}
{...field}
onChange={(e) => {
const values = form.getValues();
const characterLimit = values.characterLimit || 0;
let textValue = e.target.value;
if (characterLimit > 0 && textValue.length > characterLimit) {
textValue = textValue.slice(0, characterLimit);
}
e.target.value = textValue;
field.onChange(e);
}}
rows={1}
/>
</FormControl>
@ -175,6 +187,18 @@ export const EditorFieldTextForm = ({
className="bg-background"
placeholder={t`Field character limit`}
{...field}
onChange={(e) => {
field.onChange(e);
const values = form.getValues();
const characterLimit = parseInt(e.target.value, 10) || 0;
const textValue = values.text || '';
if (characterLimit > 0 && textValue.length > characterLimit) {
form.setValue('text', textValue.slice(0, characterLimit));
}
}}
/>
</FormControl>
<FormMessage />

View File

@ -205,6 +205,7 @@ export const DocumentSigningPageViewV2 = () => {
<div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? (
<PDFViewerKonvaLazy
renderer="signing"
key={currentEnvelopeItem.id}
documentDataId={currentEnvelopeItem.documentDataId}
customPageRenderer={EnvelopeSignerPageRenderer}

View File

@ -13,6 +13,7 @@ import { prop, sortBy } from 'remeda';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
isFieldUnsignedAndRequired,
isRequiredField,
@ -51,7 +52,11 @@ export type EnvelopeSigningContextValue = {
setSelectedAssistantRecipientId: (_value: number | null) => void;
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
signField: (
_fieldId: number,
_value: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => Promise<void>;
};
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
@ -284,7 +289,11 @@ export const EnvelopeSigningProvider = ({
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
const signField = async (
fieldId: number,
fieldValue: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
// Set the field locally for direct templates.
if (isDirectTemplate) {
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
@ -295,7 +304,7 @@ export const EnvelopeSigningProvider = ({
token: envelopeData.recipient.token,
fieldId,
fieldValue,
authOptions: undefined,
authOptions,
});
};

View File

@ -174,7 +174,7 @@ const DocumentCertificateQrV2 = ({
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
</div>
</div>
);

View File

@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() {
const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const interactiveTransformer = useRef<Transformer | null>(null);
@ -103,7 +103,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldUpdates.height = fieldPageHeight;
}
// Todo: envelopes Use id
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected.
@ -114,7 +113,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
pageLayer.current?.batchDraw();
};
const renderFieldOnLayer = (field: TLocalField) => {
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
return;
}
@ -160,6 +159,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldGroup.on('dragend', handleResizeOrMove);
};
const renderFieldOnLayer = (field: TLocalField) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/

View File

@ -27,7 +27,8 @@ import type {
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
@ -112,9 +113,34 @@ export const EnvelopeEditorFieldsPage = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex h-full justify-center p-4">
<div className="mt-4 flex flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="border-border bg-background mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
</Button>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -130,7 +156,7 @@ export const EnvelopeEditorFieldsPage = () => {
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && (
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
{/* Recipient selector section. */}
<section className="px-4">
@ -138,19 +164,6 @@ export const EnvelopeEditorFieldsPage = () => {
<Trans>Selected Recipient</Trans>
</h3>
{envelope.recipients.length === 0 ? (
<Alert variant="warning">
<AlertDescription className="flex flex-col gap-2">
<Trans>You need at least one recipient to add fields</Trans>
<Link to={`${relativePath.editorPath}`} className="text-sm">
<p>
<Trans>Click here to add a recipient</Trans>
</p>
</Link>
</AlertDescription>
</Alert>
) : (
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
@ -160,7 +173,6 @@ export const EnvelopeEditorFieldsPage = () => {
className="w-full"
align="end"
/>
)}
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (

View File

@ -229,7 +229,6 @@ export const EnvelopeEditorSettingsDialog = ({
const emails = emailData?.data || [];
// Todo: Envelopes this doesn't make sense (look at previous)
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
const onFormSubmit = async (data: TAddSettingsFormSchema) => {
@ -322,7 +321,7 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="flex w-80 flex-col border-r bg-gray-50">
<div className="bg-accent/20 flex w-80 flex-col border-r">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Document Settings</DialogTitle>
</DialogHeader>

View File

@ -203,7 +203,6 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items);
};
// Todo: Envelopes - Sync into envelopes data
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({
envelopeId: envelope.id,

View File

@ -12,7 +12,8 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
export default function EnvelopeGenericPageRenderer() {
const { i18n } = useLingui();
const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, fields, getRecipientColorKey, setRenderError } =
useCurrentEnvelopeRender();
const {
stage,
@ -37,7 +38,7 @@ export default function EnvelopeGenericPageRenderer() {
[fields, pageContext.pageNumber],
);
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
const unsafeRenderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@ -66,6 +67,15 @@ export default function EnvelopeGenericPageRenderer() {
});
};
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/

View File

@ -10,14 +10,17 @@ import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-rende
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
@ -28,20 +31,24 @@ import { handleNumberFieldClick } from '~/utils/field-signing/number-field';
import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field';
import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerPageRenderer() {
const { i18n } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const {
envelopeData,
recipient,
recipientFields,
recipientFieldsRemaining,
showPendingFieldTooltip,
signField,
signField: signFieldInternal,
email,
setEmail,
fullName,
@ -80,7 +87,7 @@ export default function EnvelopeSignerPageRenderer() {
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@ -237,7 +244,7 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); // Todo: Envelopes - Handle errors
await signField(field.id, payload);
}
if (payload?.value) {
@ -318,7 +325,6 @@ export default function EnvelopeSignerPageRenderer() {
* SIGNATURE FIELD.
*/
.with({ type: FieldType.SIGNATURE }, (field) => {
// Todo: Envelopes - Reauth
handleSignatureFieldClick({
field,
signature,
@ -329,11 +335,21 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
if (payload.value) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => {
await signField(field.id, payload, authOptions);
loadingSpinnerGroup.destroy();
},
actionTarget: field.type,
});
setSignature(payload.value);
} else {
await signField(field.id, payload);
}
if (payload?.value) {
setSignature(payload.value);
}
})
.finally(() => {
@ -347,13 +363,42 @@ export default function EnvelopeSignerPageRenderer() {
fieldGroup.on('pointerdown', handleFieldGroupClick);
};
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
try {
unsafeRenderFieldOnLayer(unparsedField);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
const signField = async (
fieldId: number,
payload: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
try {
await signFieldInternal(fieldId, payload, authOptions);
} catch (err) {
console.error(err);
toast({
title: t`Error`,
description: t`An error occurred while signing the field.`,
variant: 'destructive',
});
throw err;
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
renderFieldOnLayer(field);
}
currentPageLayer.batchDraw();
@ -369,7 +414,7 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
@ -387,7 +432,7 @@ export default function EnvelopeSignerPageRenderer() {
pageLayer.current.destroyChildren();
localPageFields.forEach((field) => {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();

View File

@ -156,7 +156,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
</CardContent>
</Card>
</EnvelopeRenderProvider>

View File

@ -179,7 +179,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
</CardContent>
</Card>
</EnvelopeRenderProvider>

View File

@ -1,5 +1,6 @@
import { FieldType } from '@prisma/client';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TFieldCheckbox } from '@documenso/lib/types/field';
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
@ -44,6 +45,13 @@ export const handleCheckboxFieldClick = async (
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index);
if (checkedValues.length === 0) {
return {
type: FieldType.CHECKBOX,
value: [],
};
}
if (validationRule && validationLength) {
const checkboxValidationRule = checkboxValidationSigns.find(
(sign) => sign.label === validationRule,
@ -55,13 +63,34 @@ export const handleCheckboxFieldClick = async (
});
}
// Custom logic to make it flow better.
// If "at most" OR "exactly" 1 value then just return the new selected value if exists.
if (
(checkboxValidationRule.value === '=' || checkboxValidationRule.value === '<=') &&
validationLength === 1
) {
return {
type: FieldType.CHECKBOX,
value: [clickedCheckboxIndex],
};
}
const isValid = validateCheckboxLength(
checkedValues.length,
checkboxValidationRule.value,
validationLength,
);
// Only render validation dialog if validation is invalid.
if (!isValid) {
checkedValues = await SignFieldCheckboxDialog.call({
fieldMeta: field.fieldMeta,
validationRule: checkboxValidationRule.value,
validationLength,
preselectedIndices: currentCheckedIndices,
preselectedIndices: checkedValues,
});
}
}
if (!checkedValues) {
return null;

View File

@ -92,12 +92,19 @@ app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_URL}/*`, cors());
app.use(`${API_V2_URL}/*`, async (c) => openApiTrpcServerHandler(c));
app.use(`${API_V2_URL}/*`, async (c) =>
openApiTrpcServerHandler(c, {
isBeta: false,
}),
);
// Redirect /api/v2-beta to /api/v2.
app.all('/api/v2-beta/*', (c) => {
const newPath = c.req.path.replace(API_V2_BETA_URL, API_V2_URL);
return c.redirect(newPath, 301);
});
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_BETA_URL}/*`, cors());
app.use(`${API_V2_BETA_URL}/*`, async (c) =>
openApiTrpcServerHandler(c, {
isBeta: true,
}),
);
export default app;

View File

@ -1,15 +1,22 @@
import type { Context } from 'hono';
import { API_V2_URL } from '@documenso/lib/constants/app';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { createOpenApiFetchHandler } from '@documenso/trpc/utils/openapi-fetch-handler';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => {
type OpenApiTrpcServerHandlerOptions = {
isBeta: boolean;
};
export const openApiTrpcServerHandler = async (
c: Context,
{ isBeta }: OpenApiTrpcServerHandlerOptions,
) => {
return createOpenApiFetchHandler<typeof appRouter>({
endpoint: API_V2_URL,
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
router: appRouter,
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
req: c.req.raw,

View File

@ -68,7 +68,7 @@ test.describe('API V2 Envelopes', () => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
@ -100,7 +100,7 @@ test.describe('API V2 Envelopes', () => {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
@ -232,14 +232,14 @@ test.describe('API V2 Envelopes', () => {
}
// Should error since folder is not owned by the user.
const invalidRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const invalidRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(invalidRes.ok()).toBeFalsy();
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
@ -378,21 +378,24 @@ test.describe('API V2 Envelopes', () => {
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
const response: TCreateEnvelopeResponse = await res.json();
expect(createEnvelopeRequest.ok()).toBeTruthy();
expect(createEnvelopeRequest.status()).toBe(200);
const createdEnvelope: TGetEnvelopeResponse = await request
.get(`${baseUrl}/envelope/${response.id}`, {
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
const getEnvelopeRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
headers: { Authorization: `Bearer ${tokenA}` },
})
.then(async (res) => await res.json());
});
const createdEnvelope: TGetEnvelopeResponse = await getEnvelopeRequest.json();
// Might as well testing access control here as well.
const unauthRequest = await request.get(`${baseUrl}/envelope/${response.id}`, {
const unauthRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});

View File

@ -78,7 +78,6 @@ test.describe('Signing Certificate Tests', () => {
},
});
// Todo: Envelopes
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
const completedDocumentData = await getFile(firstDocumentData);
@ -169,7 +168,6 @@ test.describe('Signing Certificate Tests', () => {
},
});
// Todo: Envelopes
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
const completedDocumentData = await getFile(firstDocumentData);

View File

@ -165,10 +165,7 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, {
...localFields[index],
id,
});
form.setValue(`fields.${index}.id`, id);
}
};

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
@ -25,6 +25,8 @@ export function usePageRenderer(renderFunction: RenderFunction) {
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.
*/
@ -122,5 +124,7 @@ export function usePageRenderer(renderFunction: RenderFunction) {
unscaledViewport,
scaledViewport,
pageContext,
renderError,
setRenderError,
};
}

View File

@ -27,6 +27,9 @@ type EnvelopeRenderProviderValue = {
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields'];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
renderError: boolean;
setRenderError: (renderError: boolean) => void;
};
interface EnvelopeRenderProviderProps {
@ -74,6 +77,8 @@ export const EnvelopeRenderProvider = ({
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
@ -164,6 +169,8 @@ export const EnvelopeRenderProvider = ({
setCurrentEnvelopeItem,
fields: fields ?? [],
getRecipientColorKey,
renderError,
setRenderError,
}}
>
{children}

View File

@ -250,7 +250,7 @@ export const sendDocument = async ({
);
}
if (isValid) {
if (isValid && checkedIndices.length > 0) {
fieldsToAutoInsert.push({
fieldId,
customText: toCheckboxCustomText(checkedIndices),

View File

@ -153,6 +153,11 @@ export const createFieldHoverInteraction = ({
const hoverColor = RECIPIENT_COLOR_STYLES[options.color].baseRingHover;
fieldGroup.on('mouseover', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -161,6 +166,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('mouseout', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -169,6 +179,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('transformstart', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -177,6 +192,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('transformend', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,

View File

@ -63,16 +63,15 @@ export const renderCheckboxFieldElement = (
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const text = fieldGroup
.find('.checkbox-text')
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const groupedItems = squares.map((square, i) => ({
squareElement: square,

View File

@ -8,9 +8,9 @@ import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { TFieldMetaSchema } from '../../types/field-meta';
import { renderCheckboxFieldElement } from './render-checkbox-field';
import { renderDropdownFieldElement } from './render-dropdown-field';
import { renderGenericTextFieldElement } from './render-generic-text-field';
import { renderRadioFieldElement } from './render-radio-field';
import { renderSignatureFieldElement } from './render-signature-field';
import { renderTextFieldElement } from './render-text-field';
export const MIN_FIELD_HEIGHT_PX = 12;
export const MIN_FIELD_WIDTH_PX = 36;
@ -43,9 +43,9 @@ type RenderFieldOptions = {
*
* @default 'edit'
*
* - `edit` - The field is rendered in edit mode.
* - `sign` - The field is rendered in sign mode. No interactive elements.
* - `export` - The field is rendered in export mode. No backgrounds, interactive elements, etc.
* - `edit` - The field is rendered in editor page.
* - `sign` - The field is rendered for the signing page.
* - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc.
*/
mode: 'edit' | 'sign' | 'export';
@ -76,10 +76,21 @@ export const renderField = ({
};
return match(field.type)
.with(FieldType.TEXT, () => renderTextFieldElement(field, options))
.with(
FieldType.INITIALS,
FieldType.NAME,
FieldType.EMAIL,
FieldType.DATE,
FieldType.TEXT,
FieldType.NUMBER,
() => renderGenericTextFieldElement(field, options),
)
.with(FieldType.CHECKBOX, () => renderCheckboxFieldElement(field, options))
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
.otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
.with(FieldType.FREE_SIGNATURE, () => {
throw new Error('Free signature fields are not supported');
})
.exhaustive();
};

View File

@ -12,6 +12,8 @@ import {
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
const DEFAULT_TEXT_ALIGN = 'left';
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
@ -31,8 +33,8 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || DEFAULT_TEXT_ALIGN;
const textVerticalAlign: 'top' | 'middle' | 'bottom' = 'middle';
const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
@ -40,51 +42,33 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Handle edit mode.
if (mode === 'edit') {
if (textMeta?.text) {
textToRender = textMeta.text;
} else if (textMeta?.label) {
textToRender = textMeta.label;
} else {
// Show field name which is centered for the edit mode if no label/text is avaliable.
textToRender = fieldTypeName;
textAlign = 'center';
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
} else if (textMeta?.text) {
textToRender = textMeta.text;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = fieldTypeName;
textAlign = 'center';
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
}
if (!field.inserted) {
if (textMeta?.text) {
textToRender = textMeta.text;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
} else if (textMeta?.label) {
textToRender = textMeta.label;
} else if (mode === 'sign') {
// Only show the field name in sign mode if no text/label is avaliable.
textToRender = fieldTypeName;
textAlign = 'center';
}
}
if (field.inserted) {
textToRender = field.customText;
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta?.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
}
}
@ -106,7 +90,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
return fieldText;
};
export const renderTextFieldElement = (
export const renderGenericTextFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {

View File

@ -104,7 +104,6 @@ export const extractFieldInsertionValues = ({
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(fieldValue.value.toString(), numberFieldParsedMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid number',
@ -127,7 +126,6 @@ export const extractFieldInsertionValues = ({
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
@ -189,7 +187,6 @@ export const extractFieldInsertionValues = ({
(sign) => sign.label === validationRule,
);
// Todo: Envelopes - Test this.
if (checkboxValidationRule) {
const isValid = validateCheckboxLength(
selectedValues.length,
@ -224,7 +221,6 @@ export const extractFieldInsertionValues = ({
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
// Todo: Envelopes
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid dropdown value',

View File

@ -13,6 +13,7 @@ export const ZSetEnvelopeFieldsRequestSchema = z.object({
envelopeId: z.string(),
envelopeType: z.nativeEnum(EnvelopeType),
fields: z.array(
// Todo: Envelopes - Use strict schema for types + field meta.
z.object({
id: z
.number()

View File

@ -133,6 +133,49 @@ export const signEnvelopeFieldRoute = procedure
const insertionValues = extractFieldInsertionValues({ fieldValue, field, documentMeta });
// Early return for uninserting fields.
if (!insertionValues.inserted) {
return await prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
id: field.id,
},
data: {
customText: '',
inserted: false,
},
});
await tx.signature.deleteMany({
where: {
fieldId: field.id,
},
});
if (recipient.role !== RecipientRole.ASSISTANT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
envelopeId: envelope.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata: metadata.requestMetadata,
data: {
field: field.type,
fieldId: field.secondaryId,
},
}),
});
}
return {
signedField: updatedField,
};
});
}
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: envelope.authOptions,
recipient,

View File

@ -2,11 +2,14 @@ import React, { Suspense, lazy } from 'react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
import type { PdfViewerRendererMode } from './pdf-viewer-konva';
export type LoadedPDFDocument = PDFDocumentProxy;
export type PDFViewerProps = {
className?: string;
onDocumentLoad?: () => void;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;

View File

@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import Konva from 'konva';
import { Loader } from 'lucide-react';
import { type PDFDocumentProxy } from 'pdfjs-dist';
@ -8,6 +10,7 @@ import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
export type LoadedPDFDocument = PDFDocumentProxy;
@ -29,10 +32,31 @@ const PDFLoader = () => (
</>
);
export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing';
const RendererErrorMessages: Record<
PdfViewerRendererMode,
{ title: MessageDescriptor; description: MessageDescriptor }
> = {
editor: {
title: msg`Configuration Error`,
description: msg`There was an issue rendering some fields, please review the fields and try again.`,
},
preview: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
signing: {
title: msg`Configuration Error`,
description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`,
},
};
export type PdfViewerKonvaProps = {
className?: string;
onDocumentLoad?: () => void;
customPageRenderer?: React.FunctionComponent;
renderer: PdfViewerRendererMode;
[key: string]: unknown;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
@ -40,11 +64,14 @@ export const PdfViewerKonva = ({
className,
onDocumentLoad,
customPageRenderer,
renderer,
...props
}: PdfViewerKonvaProps) => {
const { t } = useLingui();
const $el = useRef<HTMLDivElement>(null);
const { getPdfBuffer, currentEnvelopeItem } = useCurrentEnvelopeRender();
const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
@ -92,6 +119,13 @@ export const PdfViewerKonva = ({
return (
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{renderError && (
<Alert variant="destructive" className="mb-4 max-w-[800px]">
<AlertTitle>{t(RendererErrorMessages[renderer].title)}</AlertTitle>
<AlertDescription>{t(RendererErrorMessages[renderer].description)}</AlertDescription>
</Alert>
)}
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}

View File

@ -66,7 +66,7 @@ export const DocumentDropzone = ({
const heading = {
document: msg`Upload Document`,
template: msg`Upload Template Document`,
envelope: msg`Envelope (beta)`,
envelope: msg`Upload Envelope`,
};
if (disabled && IS_BILLING_ENABLED()) {