feat: polish envelopes (#2090)

## Description

The rest of the owl
This commit is contained in:
David Nguyen
2025-10-24 16:22:06 +11:00
committed by GitHub
parent 88836404d1
commit 03eb6af69a
141 changed files with 5171 additions and 2402 deletions
@@ -1,3 +1,5 @@
import { match } from 'ts-pattern';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { TCheckboxFieldMeta } from '../types/field-meta';
@@ -75,3 +77,15 @@ export const validateCheckboxField = (
return errors;
};
export const validateCheckboxLength = (
numberOfSelectedOptions: number,
validationRule: '>=' | '=' | '<=',
validationLength: number,
) => {
return match(validationRule)
.with('>=', () => numberOfSelectedOptions >= validationLength)
.with('=', () => numberOfSelectedOptions === validationLength)
.with('<=', () => numberOfSelectedOptions <= validationLength)
.exhaustive();
};
@@ -29,7 +29,7 @@ export const validateNumberField = (
errors.push('Value is required');
}
if (!/^[0-9,.]+$/.test(value.trim())) {
if ((isSigningPage || value.length > 0) && !/^[0-9,.]+$/.test(value.trim())) {
errors.push(`Value is not a valid number`);
}
@@ -123,7 +123,6 @@ export const useEditorFields = ({
}
if (bypassCheck) {
console.log(3);
setSelectedFieldFormId(formId);
return;
}
@@ -136,6 +135,7 @@ export const useEditorFields = ({
const field: TLocalField = {
...fieldData,
formId: nanoid(12),
...restrictFieldPosValues(fieldData),
};
append(field);
@@ -165,7 +165,15 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, { ...localFields[index], ...updates });
const updatedField = {
...localFields[index],
...updates,
};
update(index, {
...updatedField,
...restrictFieldPosValues(updatedField),
});
triggerFieldsUpdate();
}
},
@@ -279,3 +287,14 @@ export const useEditorFields = ({
setSelectedRecipient,
};
};
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)),
width: Math.max(0, Math.min(100, field.width)),
height: Math.max(0, Math.min(100, field.height)),
};
};
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) {
const pageContext = usePageContext();
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);
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
);
/**
* The viewport scaled according to page width.
*/
const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, 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],
);
/**
* Render the PDF and create the scaled Konva stage.
*/
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: kContainer } = konvaContainer;
if (!canvas || !kContainer) {
return;
}
canvas.width = renderViewport.width;
canvas.height = renderViewport.height;
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: renderViewport,
};
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,
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
);
return {
canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
pageContext,
};
}
@@ -5,15 +5,14 @@ import { EnvelopeType } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { RecipientColorStyles, TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import {
AVAILABLE_RECIPIENT_COLORS,
getRecipientColorStyles,
} from '@documenso/ui/lib/recipient-colors';
import 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 { useToast } from '@documenso/ui/primitives/use-toast';
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 { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
@@ -38,25 +37,35 @@ export const useDebounceFunction = <Args extends unknown[]>(
);
};
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
type EnvelopeEditorProviderValue = {
envelope: TEnvelope;
isDocument: boolean;
isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
getFieldColor: (field: TLocalField) => RecipientColorStyles;
getRecipientColorKey: (recipientId: number) => TRecipientColor;
editorFields: ReturnType<typeof useEditorFields>;
isAutosaving: boolean;
flushAutosave: () => void;
flushAutosave: () => Promise<void>;
autosaveError: boolean;
relativePath: {
basePath: string;
envelopePath: string;
editorPath: string;
documentRootPath: string;
templateRootPath: string;
};
syncEnvelope: () => Promise<void>;
// refetchEnvelope: () => Promise<void>;
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
};
@@ -86,12 +95,10 @@ export const EnvelopeEditorProvider = ({
const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
console.log(input.meta?.emailSettings);
setEnvelope({
...envelope,
...response,
@@ -106,7 +113,9 @@ export const EnvelopeEditorProvider = ({
setAutosaveError(false);
},
onError: (error) => {
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
@@ -122,7 +131,9 @@ export const EnvelopeEditorProvider = ({
onSuccess: () => {
setAutosaveError(false);
},
onError: (error) => {
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
@@ -135,10 +146,17 @@ export const EnvelopeEditorProvider = ({
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: () => {
onSuccess: ({ recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
}));
setAutosaveError(false);
},
onError: (error) => {
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
@@ -178,21 +196,28 @@ export const EnvelopeEditorProvider = ({
triggerSave: setEnvelopeDebounced,
flush: setEnvelopeAsync,
isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: Partial<TEnvelope>) => {
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: {
...envelopeUpdates,
},
data: envelopeUpdates.data,
meta: envelopeUpdates.meta,
});
}, 1000);
/**
* Updates the local envelope and debounces the update to the server.
*/
const updateEnvelope = (envelopeUpdates: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...envelopeUpdates }));
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
setEnvelope((prev) => ({
...prev,
...envelopeUpdates.data,
meta: {
...prev.documentMeta,
...envelopeUpdates.meta,
},
}));
setEnvelopeDebounced(envelopeUpdates);
};
@@ -201,28 +226,17 @@ export const EnvelopeEditorProvider = ({
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const getFieldColor = useCallback(
(field: TLocalField) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === field.recipientId,
);
return getRecipientColorStyles(Math.max(recipientIndex, 0));
},
[envelope.recipients], // Todo: Envelopes - Local recipients
);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId,
);
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
[envelope.recipients], // Todo: Envelopes - Local recipients
[envelope.recipients],
);
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
@@ -234,6 +248,21 @@ export const EnvelopeEditorProvider = ({
},
);
/**
* Fetch and sycn the envelope back into the editor.
*
* Overrides everything.
*/
const syncEnvelope = async () => {
await flushAutosave();
const fetchedEnvelopeData = await reloadEnvelope();
if (fetchedEnvelopeData.data) {
setEnvelope(fetchedEnvelopeData.data);
}
};
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
};
@@ -256,10 +285,23 @@ export const EnvelopeEditorProvider = ({
isEnvelopeMutationPending,
]);
const flushAutosave = () => {
void setFieldsAsync();
void setRecipientsAsync();
void setEnvelopeAsync();
const relativePath = useMemo(() => {
const documentRootPath = formatDocumentsPath(envelope.team.url);
const templateRootPath = formatTemplatesPath(envelope.team.url);
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
return {
basePath,
envelopePath: `${basePath}/${envelope.id}`,
editorPath: `${basePath}/${envelope.id}/edit`,
documentRootPath,
templateRootPath,
};
}, [envelope.type, envelope.id]);
const flushAutosave = async (): Promise<void> => {
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
};
return (
@@ -269,7 +311,6 @@ export const EnvelopeEditorProvider = ({
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope,
getFieldColor,
getRecipientColorKey,
updateEnvelope,
setRecipientsDebounced,
@@ -278,6 +319,8 @@ export const EnvelopeEditorProvider = ({
autosaveError,
flushAutosave,
isAutosaving,
relativePath,
syncEnvelope,
}}
>
{children}
+1
View File
@@ -2,6 +2,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 12;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const DEFAULT_SIGNATURE_TEXT_FONT_SIZE = 18;
export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20;
@@ -18,6 +18,8 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
embedSigning: z.literal(true).optional(),
embedSigningWhiteLabel: z.literal(true).optional(),
cfr21: z.literal(true).optional(),
// Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here.
}),
});
@@ -1,4 +1,12 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import {
PDFDocument,
RotationTypes,
popGraphicsState,
pushGraphicsState,
radiansToDegrees,
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
@@ -9,6 +17,8 @@ import {
} from '@prisma/client';
import { nanoid } from 'nanoid';
import path from 'node:path';
import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing';
@@ -21,6 +31,7 @@ import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificat
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { getPageSize } from '../../../server-only/pdf/get-page-size';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
@@ -178,9 +189,10 @@ export const run = async ({
settings,
});
// Todo: Envelopes - Is it okay to have dynamic IDs?
const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) =>
io.runTask('decorate-and-sign-pdf', async () => {
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
@@ -353,14 +365,95 @@ const decorateAndSignPdf = async ({
});
}
for (const field of envelopeItemFields) {
if (field.inserted) {
if (envelope.internalVersion === 2) {
await insertFieldInPDFV2(pdfDoc, field);
} else if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field);
} else {
await insertFieldInPDFV1(pdfDoc, field);
// Handle V1 and legacy insertions.
if (envelope.internalVersion === 1) {
for (const field of envelopeItemFields) {
if (field.inserted) {
if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field);
} else {
await insertFieldInPDFV1(pdfDoc, field);
}
}
}
}
// Handle V2 envelope insertions.
if (envelope.internalVersion === 2) {
const fieldsGroupedByPage = groupBy(envelopeItemFields, (field) => field.page);
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
const pageRotation = page.getRotation();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
// Rotate the page to the orientation that the react-pdf renders on the frontend.
// Note: These transformations are undone at the end of the function.
// If you change this if statement, update the if statement at the end as well
if (pageRotationInDegrees !== 0) {
let translateX = 0;
let translateY = 0;
switch (pageRotationInDegrees) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
case 0:
default:
translateX = 0;
translateY = 0;
}
page.pushOperators(pushGraphicsState());
page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees));
}
const renderedPdfOverlay = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields,
});
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay);
// Draw the SVG on the page
page.drawPage(embeddedPage, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
// Remove the transformations applied to the page if any were applied.
if (pageRotationInDegrees !== 0) {
page.pushOperators(popGraphicsState());
}
}
}
@@ -32,6 +32,7 @@ export const getDocumentWithDetailsById = async ({
return {
...envelope,
envelopeId: envelope.id,
internalVersion: envelope.internalVersion,
documentData: {
...firstDocumentData,
envelopeItemId: envelope.envelopeItems[0].id,
@@ -3,6 +3,7 @@ import {
DocumentSigningOrder,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
SendStatus,
SigningStatus,
@@ -13,9 +14,13 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@@ -24,6 +29,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -56,6 +62,7 @@ export const sendDocument = async ({
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
fields: true,
documentMeta: true,
envelopeItems: {
select: {
@@ -165,6 +172,78 @@ export const sendDocument = async ({
});
}
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
// Auto insert radio and checkboxes that have default values.
if (envelope.internalVersion === 2) {
for (const field of envelope.fields) {
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toRadioCustomText(checkedItemIndex),
});
}
}
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: defaultValue,
});
}
}
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
});
let isValid = true;
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(
checkedIndices.length,
validation.value,
validationLength,
);
}
if (isValid) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toCheckboxCustomText(checkedIndices),
});
}
}
}
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
@@ -177,6 +256,23 @@ export const sendDocument = async ({
});
}
// Todo: Envelopes - [AUDIT_LOGS]
if (envelope.internalVersion === 2) {
await Promise.all(
fieldsToAutoInsert.map(async (field) => {
await tx.field.update({
where: {
id: field.fieldId,
},
data: {
customText: field.customText,
inserted: true,
},
});
}),
);
}
return await tx.envelope.update({
where: {
id: envelope.id,
@@ -0,0 +1,144 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
export type GetRecipientEnvelopeByTokenOptions = {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
};
/**
* Get all the values and details for a direct template envelope that a recipient requires.
*
* Do not overexpose any information that the recipient should not have.
*/
export const getEnvelopeForDirectTemplateSigning = async ({
token,
userId,
accessAuth,
}: GetRecipientEnvelopeByTokenOptions): Promise<EnvelopeForSigningResponse> => {
if (!token) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Missing token',
});
}
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
status: DocumentStatus.DRAFT,
directLink: {
enabled: true,
token,
},
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
recipients: {
include: {
fields: {
include: {
signature: true,
},
},
},
orderBy: {
signingOrder: 'asc',
},
},
envelopeItems: {
include: {
documentData: true,
},
},
team: {
select: {
id: true,
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
directLink: true,
},
});
const recipient = (envelope?.recipients || []).find(
(r) => r.id === envelope?.directLink?.directTemplateRecipientId,
);
if (!envelope || !recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope has no items',
});
}
const documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: envelope.authOptions,
recipient,
userId,
authOptions: accessAuth,
});
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
? {
email: envelope.user.email,
name: envelope.user.name || '',
}
: {
email: envelope.team.teamEmail?.email || '',
name: envelope.team.name || '',
};
return ZEnvelopeForSigningResponse.parse({
envelope,
recipient: {
...recipient,
token: envelope.directLink?.token || '',
},
recipientSignature: null,
isRecipientsTurn: true,
isCompleted: false,
isRejected: false,
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
brandingEnabled: settings.brandingEnabled,
brandingLogo: settings.brandingLogo,
},
} satisfies EnvelopeForSigningResponse);
};
@@ -23,7 +23,7 @@ export type GetRecipientEnvelopeByTokenOptions = {
accessAuth?: TDocumentAuthMethods;
};
const ZEnvelopeForSigningResponse = z.object({
export const ZEnvelopeForSigningResponse = z.object({
envelope: EnvelopeSchema.pick({
type: true,
status: true,
@@ -31,6 +31,7 @@ const ZEnvelopeForSigningResponse = z.object({
secondaryId: true,
internalVersion: true,
completedAt: true,
updatedAt: true,
deletedAt: true,
title: true,
authOptions: true,
@@ -54,3 +54,54 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};
@@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
false,
);
if (errors.length > 0) {
@@ -205,6 +205,7 @@ export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalCla
flags: {
...subscriptionClaim.flags,
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
};
@@ -1,133 +1,64 @@
import type { PDFDocument } from '@cantoo/pdf-lib';
import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { match } from 'ts-pattern';
import { FontLibrary } from 'skia-canvas';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderField } from '../../universal/field-renderer/render-field';
import { getPageSize } from './get-page-size';
// const font = await pdf.embedFont(
// isSignatureField ? fontCaveat : fontNoto,
// isSignatureField ? { features: { calt: false } } : undefined,
// );
// const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
// const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
type InsertFieldInPDFV2Options = {
pageWidth: number;
pageHeight: number;
fields: FieldWithSignature[];
};
export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
export const insertFieldInPDFV2 = async ({
pageWidth,
pageHeight,
fields,
}: InsertFieldInPDFV2Options) => {
const fontPath = path.join(process.cwd(), 'public/fonts');
FontLibrary.use([
path.join(fontPath, 'caveat.ttf'),
path.join(fontPath, 'noto-sans.ttf'),
path.join(fontPath, 'noto-sans-japanese.ttf'),
path.join(fontPath, 'noto-sans-chinese.ttf'),
path.join(fontPath, 'noto-sans-korean.ttf'),
]);
const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit);
const pages = pdf.getPages();
const page = pages.at(field.page - 1);
if (!page) {
throw new Error(`Page ${field.page} does not exist`);
}
const pageRotation = page.getRotation();
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
// Todo: Evenloeps - getPageSize this had extra logic? Ask lucas
console.log({
cropBox: page.getCropBox(),
mediaBox: page.getMediaBox(),
mediaBox2: page.getSize(),
});
const { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
//
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
//
// Then when we insert the fields, we apply a transformation to the position of the field
// so it is rotated correctly.
if (isPageRotatedToLandscape) {
// [pageWidth, pageHeight] = [pageHeight, pageWidth];
}
console.log({
pageWidth,
pageHeight,
fieldWidth: field.width,
fieldHeight: field.height,
});
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
const layer = new Konva.Layer();
// Will render onto the layer.
renderField({
field: {
renderId: field.id.toString(),
...field,
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
},
pageLayer: layer,
pageWidth,
pageHeight,
mode: 'export',
});
const insertedFields = fields.filter((field) => field.inserted);
// Render the fields onto the layer.
for (const field of insertedFields) {
renderField({
scale: 1,
field: {
renderId: field.id.toString(),
...field,
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
},
translations: null,
pageLayer: layer,
pageWidth,
pageHeight,
mode: 'export',
});
}
stage.add(layer);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const canvas = layer.canvas._canvas as unknown as Canvas;
const renderedField = await canvas.toBuffer('svg');
fs.writeFileSync(
`rendered-field-${field.envelopeId}--${field.id}.svg`,
renderedField.toString('utf-8'),
);
// Embed the SVG into the PDF
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
// Calculate position to cover the whole page
// pdf-lib coordinates: (0,0) is bottom-left, y increases upward
const svgWidth = pageWidth; // Use full page width
const svgHeight = pageHeight; // Use full page height
const x = 0; // Start from left edge
const y = pageHeight; // Start from bottom edge
// Draw the SVG on the page
page.drawSvg(svgElement, {
x: x,
y: y,
width: svgWidth,
height: svgHeight,
});
return pdf;
return await canvas.toBuffer('pdf');
};
@@ -162,12 +162,6 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
if (user && user.email !== directRecipientEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Email must match if you are logged in',
});
}
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
extractDocumentAuthMethods({
documentAuth: directTemplateEnvelope.authOptions,
@@ -340,7 +334,7 @@ export const createDocumentFromDirectTemplate = async ({
id: prefixedId('envelope'),
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: 1,
internalVersion: directTemplateEnvelope.internalVersion,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: directTemplateEnvelopeLegacyId,
@@ -209,6 +209,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
type: 'radio',
label: field.label,
values: newValues,
direction: radioMeta.direction ?? 'vertical',
};
return meta;
@@ -395,8 +396,6 @@ export const createDocumentFromTemplate = async ({
};
});
const firstEnvelopeItemId = template.envelopeItems[0].id;
// Key = original envelope item ID
// Value = duplicated envelope item ID.
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
@@ -407,10 +406,14 @@ export const createDocumentFromTemplate = async ({
template.envelopeItems.map(async (item, i) => {
let documentDataIdToDuplicate = item.documentDataId;
const foundCustomDocumentData = customDocumentData.find(
(customDocumentDataItem) =>
customDocumentDataItem.envelopeItemId || firstEnvelopeItemId === item.id,
);
const foundCustomDocumentData = customDocumentData.find((customDocumentDataItem) => {
// Handle empty envelopeItemId for backwards compatibility reasons.
if (customDocumentDataItem.documentDataId && !customDocumentDataItem.envelopeItemId) {
return true;
}
return customDocumentDataItem.envelopeItemId === item.id;
});
if (foundCustomDocumentData) {
documentDataIdToDuplicate = foundCustomDocumentData.documentDataId;
@@ -38,7 +38,6 @@ export const getTemplateByDirectLinkToken = async ({
const directLink = envelope?.directLink;
// Todo: Envelopes
const firstDocumentData = envelope?.envelopeItems[0]?.documentData;
// Doing this to enforce type safety for directLink.
+3
View File
@@ -33,6 +33,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
folderId: true,
}).extend({
envelopeId: z.string(),
internalVersion: z.number(),
// Which "Template" the document was created from.
templateId: z
@@ -114,6 +115,7 @@ export const ZDocumentLiteSchema = LegacyDocumentSchema.pick({
useLegacyFieldInsertion: true,
}).extend({
envelopeId: z.string(),
internalVersion: z.number(),
// Backwards compatibility.
documentDataId: z.string().default(''),
@@ -149,6 +151,7 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({
useLegacyFieldInsertion: true,
}).extend({
envelopeId: z.string(),
internalVersion: z.number(),
// Backwards compatibility.
documentDataId: z.string().default(''),
+1 -1
View File
@@ -79,7 +79,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
type: true,
id: true,
data: true,
initialData: true, // Todo: Envelopes - Maybe this hide this.
initialData: true,
}),
})
.array(),
+28 -13
View File
@@ -1,6 +1,8 @@
import { FieldType } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../constants/pdf';
export const DEFAULT_FIELD_FONT_SIZE = 14;
export const ZBaseFieldMeta = z.object({
@@ -8,6 +10,7 @@ export const ZBaseFieldMeta = z.object({
placeholder: z.string().optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
@@ -18,7 +21,6 @@ export type TFieldTextAlignSchema = z.infer<typeof ZFieldTextAlignSchema>;
export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('initials'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@@ -26,7 +28,6 @@ export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
export const ZNameFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('name'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@@ -34,7 +35,6 @@ export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
export const ZEmailFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('email'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@@ -42,7 +42,6 @@ export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
export const ZDateFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('date'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@@ -52,7 +51,6 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('text'),
text: z.string().optional(),
characterLimit: z.number().optional(),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@@ -64,7 +62,6 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
value: z.string().optional(),
minValue: z.coerce.number().nullish(),
maxValue: z.coerce.number().nullish(),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@@ -81,6 +78,7 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
}),
)
.optional(),
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
});
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
@@ -111,7 +109,14 @@ export const ZDropdownFieldMeta = ZBaseFieldMeta.extend({
export type TDropdownFieldMeta = z.infer<typeof ZDropdownFieldMeta>;
export const ZSignatureFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('signature'),
});
export type TSignatureFieldMeta = z.infer<typeof ZSignatureFieldMeta>;
export const ZFieldMetaNotOptionalSchema = z.discriminatedUnion('type', [
ZSignatureFieldMeta,
ZInitialsFieldMeta,
ZNameFieldMeta,
ZEmailFieldMeta,
@@ -231,13 +236,13 @@ export type TFieldAndMeta = z.infer<typeof ZFieldAndMetaSchema>;
export const FIELD_DATE_META_DEFAULT_VALUES: TDateFieldMeta = {
type: 'date',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
};
export const FIELD_TEXT_META_DEFAULT_VALUES: TTextFieldMeta = {
type: 'text',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
label: '',
placeholder: '',
@@ -245,9 +250,10 @@ export const FIELD_TEXT_META_DEFAULT_VALUES: TTextFieldMeta = {
required: false,
readOnly: false,
};
export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
type: 'number',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
label: '',
placeholder: '',
@@ -257,31 +263,34 @@ export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
export const FIELD_INITIALS_META_DEFAULT_VALUES: TInitialsFieldMeta = {
type: 'initials',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
};
export const FIELD_NAME_META_DEFAULT_VALUES: TNameFieldMeta = {
type: 'name',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
};
export const FIELD_EMAIL_META_DEFAULT_VALUES: TEmailFieldMeta = {
type: 'email',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
};
export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
type: 'radio',
fontSize: DEFAULT_FIELD_FONT_SIZE,
values: [{ id: 1, checked: false, value: '' }],
required: false,
readOnly: false,
direction: 'vertical',
};
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
type: 'checkbox',
fontSize: DEFAULT_FIELD_FONT_SIZE,
values: [{ id: 1, checked: false, value: '' }],
validationRule: '',
validationLength: 0,
@@ -292,14 +301,20 @@ export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
export const FIELD_DROPDOWN_META_DEFAULT_VALUES: TDropdownFieldMeta = {
type: 'dropdown',
fontSize: DEFAULT_FIELD_FONT_SIZE,
values: [{ value: 'Option 1' }],
defaultValue: '',
required: false,
readOnly: false,
};
export const FIELD_SIGNATURE_META_DEFAULT_VALUES: TSignatureFieldMeta = {
type: 'signature',
fontSize: DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
};
export const FIELD_META_DEFAULT_VALUES: Record<FieldType, TFieldMetaSchema> = {
[FieldType.SIGNATURE]: undefined,
[FieldType.SIGNATURE]: FIELD_SIGNATURE_META_DEFAULT_VALUES,
[FieldType.FREE_SIGNATURE]: undefined,
[FieldType.INITIALS]: FIELD_INITIALS_META_DEFAULT_VALUES,
[FieldType.NAME]: FIELD_NAME_META_DEFAULT_VALUES,
+3 -1
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { FieldSchema } from '@documenso/prisma/generated/zod/modelSchema/FieldSchema';
import {
FIELD_SIGNATURE_META_DEFAULT_VALUES,
ZCheckboxFieldMeta,
ZDateFieldMeta,
ZDropdownFieldMeta,
@@ -12,6 +13,7 @@ import {
ZNameFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZSignatureFieldMeta,
ZTextFieldMeta,
} from './field-meta';
@@ -91,7 +93,7 @@ export type TFieldText = z.infer<typeof ZFieldTextSchema>;
export const ZFieldSignatureSchema = BaseFieldSchemaUsingNumbers.extend({
type: z.literal(FieldType.SIGNATURE),
fieldMeta: z.literal(null),
fieldMeta: ZSignatureFieldMeta.catch(FIELD_SIGNATURE_META_DEFAULT_VALUES),
});
export type TFieldSignature = z.infer<typeof ZFieldSignatureSchema>;
+12
View File
@@ -30,6 +30,8 @@ export const ZClaimFlagsSchema = z.object({
cfr21: z.boolean().optional(),
authenticationPortal: z.boolean().optional(),
allowEnvelopes: z.boolean().optional(),
});
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
@@ -82,6 +84,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'authenticationPortal',
label: 'Authentication portal',
},
allowEnvelopes: {
key: 'allowEnvelopes',
label: 'Allow envelopes',
},
};
export enum INTERNAL_CLAIM_ID {
@@ -105,6 +111,7 @@ export const internalClaims: InternalClaims = {
name: 'Free',
teamCount: 1,
memberCount: 1,
envelopeItemCount: 5,
locked: true,
flags: {},
},
@@ -113,6 +120,7 @@ export const internalClaims: InternalClaims = {
name: 'Individual',
teamCount: 1,
memberCount: 1,
envelopeItemCount: 5,
locked: true,
flags: {
unlimitedDocuments: true,
@@ -123,6 +131,7 @@ export const internalClaims: InternalClaims = {
name: 'Teams',
teamCount: 1,
memberCount: 5,
envelopeItemCount: 5,
locked: true,
flags: {
unlimitedDocuments: true,
@@ -135,6 +144,7 @@ export const internalClaims: InternalClaims = {
name: 'Platform',
teamCount: 1,
memberCount: 0,
envelopeItemCount: 10,
locked: true,
flags: {
unlimitedDocuments: true,
@@ -152,6 +162,7 @@ export const internalClaims: InternalClaims = {
name: 'Enterprise',
teamCount: 0,
memberCount: 0,
envelopeItemCount: 10,
locked: true,
flags: {
unlimitedDocuments: true,
@@ -171,6 +182,7 @@ export const internalClaims: InternalClaims = {
name: 'Early Adopter',
teamCount: 0,
memberCount: 0,
envelopeItemCount: 5,
locked: true,
flags: {
unlimitedDocuments: true,
@@ -8,11 +8,15 @@ import {
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
export const konvaTextFontFamily =
'Noto Sans, Noto Sans Japanese, Noto Sans Chinese, Noto Sans Korean, sans-serif';
export const konvaTextFill = 'black';
export const upsertFieldGroup = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable } = options;
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
@@ -27,6 +31,9 @@ export const upsertFieldGroup = (
name: 'field-group',
});
const maxXPosition = (pageWidth - fieldWidth) * scale;
const maxYPosition = (pageHeight - fieldHeight) * scale;
fieldGroup.setAttrs({
scaleX: 1,
scaleY: 1,
@@ -34,8 +41,9 @@ export const upsertFieldGroup = (
y: fieldY,
draggable: editable,
dragBoundFunc: (pos) => {
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
return { x: newX, y: newY };
},
} satisfies Partial<Konva.GroupConfig>);
@@ -89,14 +97,18 @@ export const createSpinner = ({
width: fieldWidth - 8,
height: fieldHeight - 8,
fill: 'white',
opacity: 1,
opacity: 0.8,
});
const maxSpinnerSize = 10;
const smallerDimension = Math.min(fieldWidth, fieldHeight);
const spinnerSize = Math.min(smallerDimension, maxSpinnerSize);
const spinner = new Konva.Arc({
x: fieldWidth / 2,
y: fieldHeight / 2,
innerRadius: fieldWidth / 10,
outerRadius: fieldHeight / 10,
innerRadius: spinnerSize,
outerRadius: spinnerSize / 2,
angle: 270,
rotation: 0,
fill: 'rgba(122, 195, 85, 1)',
@@ -1,4 +1,4 @@
import type { Signature } from '@prisma/client';
import type { FieldType, Signature } from '@prisma/client';
import { type Field } from '@prisma/client';
import type Konva from 'konva';
@@ -26,9 +26,11 @@ export type RenderFieldElementOptions = {
pageLayer: Konva.Layer;
pageWidth: number;
pageHeight: number;
mode?: 'edit' | 'sign' | 'export';
mode: 'edit' | 'sign' | 'export';
editable?: boolean;
scale: number;
color?: TRecipientColor;
translations: Record<FieldType, string> | null;
};
/**
@@ -107,6 +109,11 @@ type CalculateMultiItemPositionOptions = {
*/
fieldPadding: number;
/**
* The direction of the items.
*/
direction: 'horizontal' | 'vertical';
type: 'checkbox' | 'radio';
};
@@ -122,6 +129,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
itemSize,
spacingBetweenItemAndText,
fieldPadding,
direction,
type,
} = options;
@@ -130,6 +138,39 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
const innerFieldX = fieldPadding;
const innerFieldY = fieldPadding;
if (direction === 'horizontal') {
const itemHeight = innerFieldHeight;
const itemWidth = innerFieldWidth / itemCount;
const y = innerFieldY;
const x = itemIndex * itemWidth + innerFieldX;
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = x;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') {
itemInputX = x + itemSize / 2;
itemInputY = y + itemHeight / 2;
}
const textX = x + itemSize + spacingBetweenItemAndText;
const textY = y;
// Multiplied by 2 for extra padding on the right hand side of the text and the next item.
const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2;
const textHeight = itemHeight;
return {
itemInputX,
itemInputY,
textX,
textY,
textWidth,
textHeight,
};
}
const itemHeight = innerFieldHeight / itemCount;
const y = itemIndex * itemHeight + innerFieldY;
@@ -137,6 +178,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = innerFieldX;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') {
itemInputX = innerFieldX + itemSize / 2;
itemInputY = y + itemHeight / 2;
@@ -1,16 +1,25 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
// Do not change any of these values without consulting with the team.
const checkboxFieldPadding = 8;
const checkboxSize = 16;
const spacingBetweenCheckboxAndText = 8;
const calculateCheckboxSize = (fontSize: number) => {
return fontSize;
};
export const renderCheckboxFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
@@ -21,134 +30,156 @@ export const renderCheckboxFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
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()));
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()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
const checkboxValues = checkboxMeta?.values || [];
const fontSize = checkboxMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
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()));
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()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: calculateCheckboxSize(fontSize),
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
checkboxValues.forEach(({ id, value, checked }, index) => {
const isCheckboxChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => checkedValues.includes(index))
.with('export', () => {
// If it's read-only, check the originally checked state.
if (checkboxMeta.readOnly) {
return checked;
}
return checkedValues.includes(index);
})
.exhaustive();
console.log('wtf?');
const itemSize = calculateCheckboxSize(fontSize);
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
fieldHeight,
itemCount: checkboxValues.length,
itemIndex: index,
itemSize: checkboxSize,
itemSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
const square = new Konva.Rect({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-square-${index}`,
name: 'checkbox-square',
x: itemInputX,
y: itemInputY,
width: checkboxSize,
height: checkboxSize,
width: itemSize,
height: itemSize,
stroke: '#374151',
strokeWidth: 2,
cornerRadius: 2,
fill: 'white',
});
const checkboxScale = itemSize / 16;
const checkmark = new Konva.Line({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-checkmark-${index}`,
name: 'checkbox-checkmark',
x: itemInputX,
@@ -156,12 +187,12 @@ export const renderCheckboxFieldElement = (
strokeWidth: 2,
stroke: '#111827',
points: [3, 8, 7, 12, 13, 4],
visible: checked,
scale: { x: checkboxScale, y: checkboxScale },
visible: isCheckboxChecked,
});
const text = new Konva.Text({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-text-${index}`,
name: 'checkbox-text',
x: textX,
@@ -169,10 +200,10 @@ export const renderCheckboxFieldElement = (
text: value,
width: textWidth,
height: textHeight,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
fill: '#111827', // Todo: Envelopes - Sort colours
});
fieldGroup.add(square);
@@ -2,7 +2,12 @@ import Konva from 'konva';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TDropdownFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
@@ -26,7 +31,7 @@ const calculateDropdownPosition = (options: CalculateDropdownPositionOptions) =>
const textY = fieldPadding;
const arrowX = fieldWidth - arrowSize - fieldPadding;
const arrowY = fieldHeight / 2;
const arrowY = fieldHeight / 2 - arrowSize / 4;
return {
arrowX,
@@ -111,6 +116,8 @@ export const renderDropdownFieldElement = (
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Todo: Envelopes - Translations
let selectedValue = 'Select Option';
@@ -132,9 +139,9 @@ export const renderDropdownFieldElement = (
width: textWidth,
height: textHeight,
text: selectedValue,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fill: '#111827',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
});
@@ -36,6 +36,8 @@ type RenderFieldOptions = {
color?: TRecipientColor;
translations: Record<FieldType, string> | null;
/**
* The render type.
*
@@ -47,15 +49,18 @@ type RenderFieldOptions = {
*/
mode: 'edit' | 'sign' | 'export';
scale: number;
editable?: boolean;
};
export const renderField = ({
field,
translations,
pageLayer,
pageWidth,
pageHeight,
mode,
scale,
editable,
color,
}: RenderFieldOptions) => {
@@ -63,9 +68,11 @@ export const renderField = ({
pageLayer,
pageWidth,
pageHeight,
translations,
mode,
color,
editable,
scale,
};
return match(field.type)
@@ -74,5 +81,5 @@ export const renderField = ({
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
.otherwise(() => renderTextFieldElement(field, options)); // Todo
.otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
};
@@ -1,16 +1,25 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
// Do not change any of these values without consulting with the team.
const radioFieldPadding = 8;
const radioSize = 16;
const spacingBetweenRadioAndText = 8;
const calculateRadioSize = (fontSize: number) => {
return fontSize;
};
export const renderRadioFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
@@ -26,110 +35,129 @@ export const renderRadioFieldElement = (
fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
});
circleElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || [];
const fontSize = radioMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
fieldGroup.off('transform');
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: calculateRadioSize(fontSize),
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
circleElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
radioValues.forEach(({ value, checked }, index) => {
const isRadioValueChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => index.toString() === field.customText)
.with('export', () => {
// If it's read-only, check the originally checked state.
if (radioMeta.readOnly) {
return checked;
}
return index.toString() === field.customText;
})
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
fieldHeight,
itemCount: radioValues.length,
itemIndex: index,
itemSize: radioSize,
itemSize: calculateRadioSize(fontSize),
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
// Circle which represents the radio button.
const circle = new Konva.Circle({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-circle-${index}`,
name: 'radio-circle',
x: itemInputX,
y: itemInputY,
radius: radioSize / 2,
radius: calculateRadioSize(fontSize) / 2,
stroke: '#374151',
strokeWidth: 2,
fill: 'white',
@@ -137,20 +165,18 @@ export const renderRadioFieldElement = (
// Dot which represents the selected state.
const dot = new Konva.Circle({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-dot-${index}`,
name: 'radio-dot',
x: itemInputX,
y: itemInputY,
radius: radioSize / 4,
radius: calculateRadioSize(fontSize) / 4,
fill: '#111827',
// Todo: Envelopes
visible: value === field.customText,
// visible: checked,
visible: isRadioValueChecked,
});
const text = new Konva.Text({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-text-${index}`,
name: 'radio-text',
x: textX,
@@ -158,10 +184,10 @@ export const renderRadioFieldElement = (
text: value,
width: textWidth,
height: textHeight,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
fill: '#111827', // Todo: Envelopes - Sort colours
});
fieldGroup.add(circle);
@@ -5,58 +5,74 @@ import {
RECIPIENT_COLOR_STYLES,
} from '@documenso/ui/lib/recipient-colors';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
} from '../../constants/pdf';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
import { AppError } from '../../errors/app-error';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SkiaImage: any = undefined;
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('skia-canvas');
SkiaImage = mod.Image;
}
})();
console.log({
pageWidth,
pageHeight,
});
const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeight: number) => {
let imageWidth = img.width;
let imageHeight = img.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
const imageX = (fieldWidth - imageWidth) / 2;
const imageY = (fieldHeight - imageHeight) / 2;
return {
width: imageWidth,
height: imageHeight,
x: imageX,
y: imageY,
};
};
const createFieldSignature = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Text | Konva.Image => {
const { pageWidth, pageHeight, mode = 'edit', translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE;
const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
const fieldText = new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
const fieldTypeName = translations?.[field.type] || field.type;
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
const textAlign = 'center';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
let textFontSize = DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
let textToRender: string = field.type;
let textToRender: string = fieldTypeName;
const signature = field.signature;
// Handle edit mode.
if (mode === 'edit') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = field.type; // Todo: Envelope - Need translations
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
textToRender = fieldTypeName;
if (field.inserted && !signature) {
throw new AppError('MISSING_SIGNATURE');
@@ -65,20 +81,57 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
if (signature?.typedSignature) {
textToRender = signature.typedSignature;
}
if (signature?.signatureImageAsBase64) {
if (typeof window !== 'undefined') {
// Create a new HTML Image element
const img = new Image();
const image = new Konva.Image({
image: img,
x: 0,
y: 0,
width: fieldWidth,
height: fieldHeight,
});
img.onload = () => {
image.setAttrs({
...getImageDimensions(img, fieldWidth, fieldHeight),
});
};
img.src = signature.signatureImageAsBase64;
return image;
} else {
// Node.js with skia-canvas
if (!SkiaImage) {
throw new Error('Skia image not found');
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(signature?.signatureImageAsBase64) as unknown as HTMLImageElement;
const image = new Konva.Image({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
return image;
}
}
}
fieldText.setAttrs({
x: textX,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
verticalAlign: 'middle',
wrap: 'char',
text: textToRender,
fontSize: textFontSize,
fontFamily: 'Caveat, Inter', // Todo: Envelopes - Fix all fonts for sans
align: textAlign,
fontSize,
fontFamily: 'Caveat, sans-serif',
align: 'center',
width: fieldWidth,
height: fieldHeight,
} satisfies Partial<Konva.TextConfig>);
@@ -96,78 +149,63 @@ export const renderSignatureFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
console.log({
rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldSignature = createFieldSignature(field, options);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldSignature);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldSignature.scaleX(1 / groupScaleX);
fieldSignature.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
fieldSignature.width(rectWidth);
fieldSignature.height(rectHeight);
// Force Konva to recalculate text layout
fieldSignature.height();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldSignature.scaleX(1);
fieldSignature.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldSignature.width(rectWidth); // Account for padding
fieldSignature.height(rectHeight);
// Force Konva to recalculate text layout
fieldSignature.height();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
// Hide the rectangle.
@@ -7,12 +7,19 @@ import {
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TTextFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
const fieldTypeName = translations?.[field.type] || field.type;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
@@ -30,24 +37,21 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
const textY = 0;
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
let textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
let textToRender: string = field.type;
let textToRender: string = fieldTypeName;
// Handle edit mode.
if (mode === 'edit') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
textAlign = 'center';
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
} else if (textMeta?.text) {
textToRender = textMeta.text;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
@@ -59,19 +63,16 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
textAlign = 'center';
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
}
if (textMeta?.text) {
textToRender = textMeta.text;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
@@ -82,7 +83,6 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
if (field.inserted) {
textToRender = field.customText;
textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
@@ -98,11 +98,10 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
text: textToRender,
fontSize: textFontSize,
fontFamily: 'Inter, system-ui, sans-serif',
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
align: textAlign,
width: fieldWidth,
height: fieldHeight,
@@ -121,77 +120,62 @@ export const renderTextFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth);
fieldText.height(rectHeight);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
console.log({
rectWidth,
});
fieldGroup.getLayer()?.batchDraw();
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Force Konva to recalculate text layout
fieldText.height();
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
+2
View File
@@ -76,6 +76,7 @@ export const mapEnvelopeToDocumentLite = (envelope: Envelope): TDocumentLite =>
return {
id: documentId, // Use legacy ID.
envelopeId: envelope.id,
internalVersion: envelope.internalVersion,
visibility: envelope.visibility,
status: envelope.status,
source: envelope.source,
@@ -115,6 +116,7 @@ export const mapEnvelopesToDocumentMany = (
return {
id: legacyDocumentId, // Use legacy ID.
envelopeId: envelope.id,
internalVersion: envelope.internalVersion,
visibility: envelope.visibility,
status: envelope.status,
source: envelope.source,
+263
View File
@@ -0,0 +1,263 @@
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TDocumentMeta } from '@documenso/lib/types/document-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText, toRadioCustomText } from '@documenso/lib/utils/fields';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
export type ExtractFieldInsertionValuesOptions = {
fieldValue: TSignEnvelopeFieldValue;
field: Field;
documentMeta: Pick<TDocumentMeta, 'timezone' | 'dateFormat' | 'typedSignatureEnabled'>;
};
export const extractFieldInsertionValues = ({
fieldValue,
field,
documentMeta,
}: ExtractFieldInsertionValuesOptions): { customText: string; inserted: boolean } => {
return match(fieldValue)
.with({ type: FieldType.EMAIL }, (fieldValue) => {
const parsedEmailValue = z.string().email().nullable().safeParse(fieldValue.value);
if (!parsedEmailValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
});
}
if (parsedEmailValue.data === null) {
return {
customText: '',
inserted: false,
};
}
return {
customText: parsedEmailValue.data,
inserted: true,
};
})
.with({ type: P.union(FieldType.NAME, FieldType.INITIALS) }, (fieldValue) => {
const parsedGenericStringValue = z.string().min(1).nullable().safeParse(fieldValue.value);
if (!parsedGenericStringValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Value is required',
});
}
if (parsedGenericStringValue.data === null) {
return {
customText: '',
inserted: false,
};
}
return {
customText: parsedGenericStringValue.data,
inserted: true,
};
})
.with({ type: FieldType.DATE }, (fieldValue) => {
if (!fieldValue.value) {
return {
customText: '',
inserted: false,
};
}
return {
customText: DateTime.now()
.setZone(documentMeta.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT),
inserted: true,
};
})
.with({ type: FieldType.NUMBER }, (fieldValue) => {
if (!fieldValue.value) {
return {
customText: '',
inserted: false,
};
}
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',
});
}
return {
customText: fieldValue.value.toString(),
inserted: true,
};
})
.with({ type: FieldType.TEXT }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
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',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.RADIO }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedRadioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const radioFieldValues = parsedRadioFieldParsedMeta.values || [];
if (!radioFieldValues[fieldValue.value]) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid radio value',
});
}
return {
customText: toRadioCustomText(fieldValue.value),
inserted: true,
};
})
.with({ type: FieldType.CHECKBOX }, (fieldValue) => {
if (fieldValue.value === null || fieldValue.value.length === 0) {
return {
customText: '',
inserted: false,
};
}
const parsedCheckboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkboxFieldValues = parsedCheckboxFieldParsedMeta.values || [];
const { value } = fieldValue;
const selectedValues = value.map((valueIndex) => checkboxFieldValues[valueIndex]);
if (selectedValues.some((value) => !value)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid checkbox values',
});
}
const { validationRule, validationLength } = parsedCheckboxFieldParsedMeta;
if (validationRule && validationLength) {
const checkboxValidationRule = checkboxValidationSigns.find(
(sign) => sign.label === validationRule,
);
// Todo: Envelopes - Test this.
if (checkboxValidationRule) {
const isValid = validateCheckboxLength(
selectedValues.length,
checkboxValidationRule.value,
validationLength,
);
if (!isValid) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Checkbox values failed length validation',
});
}
} else {
// Should throw an error, but we don't want to throw configuration errors during signing.
// Todo: Logging.
}
}
return {
customText: toCheckboxCustomText(fieldValue.value),
inserted: true,
};
})
.with({ type: FieldType.DROPDOWN }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
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',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.SIGNATURE }, (fieldValue) => {
const { value } = fieldValue;
if (!value) {
return {
customText: '',
inserted: false,
};
}
const isBase64 = isBase64Image(value);
if (documentMeta.typedSignatureEnabled === false && !isBase64) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Typed signatures are not allowed. Please draw your signature',
});
}
return {
customText: '',
inserted: true,
};
})
.exhaustive();
};
+35 -1
View File
@@ -1,4 +1,6 @@
import { type Envelope, type Field } from '@prisma/client';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type Envelope, type Field, FieldType } from '@prisma/client';
import { extractLegacyIds } from '../universal/id';
@@ -77,3 +79,35 @@ export const mapFieldToLegacyField = (
...legacyId,
};
};
export const parseCheckboxCustomText = (customText: string): number[] => {
return JSON.parse(customText);
};
export const toCheckboxCustomText = (checkedValues: number[]): string => {
return JSON.stringify(checkedValues);
};
export const parseRadioCustomText = (customText: string): number => {
return Number(customText);
};
export const toRadioCustomText = (value: number): string => {
return value.toString();
};
export const getClientSideFieldTranslations = ({ t }: I18n): Record<FieldType, string> => {
return {
[FieldType.TEXT]: t(msg`Text`),
[FieldType.CHECKBOX]: t(msg`Checkbox`),
[FieldType.RADIO]: t(msg`Radio`),
[FieldType.DROPDOWN]: t(msg`Dropdown`),
[FieldType.SIGNATURE]: t(msg`Signature`),
[FieldType.FREE_SIGNATURE]: t(msg`Free Signature`),
[FieldType.INITIALS]: t(msg`Initials`),
[FieldType.NAME]: t(msg`Name`),
[FieldType.NUMBER]: t(msg`Number`),
[FieldType.DATE]: t(msg`Date`),
[FieldType.EMAIL]: t(msg`Email`),
};
};
@@ -1,5 +1,7 @@
import type { SubscriptionClaim } from '@prisma/client';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from '@documenso/ee/server-only/limits/constants';
export const generateDefaultSubscriptionClaim = (): Omit<
SubscriptionClaim,
'id' | 'organisation' | 'createdAt' | 'updatedAt' | 'originalSubscriptionClaimId'
@@ -8,6 +10,7 @@ export const generateDefaultSubscriptionClaim = (): Omit<
name: '',
teamCount: 1,
memberCount: 1,
envelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
locked: false,
flags: {},
};