diff --git a/apps/remix/app/components/forms/editor/editor-field-date-form.tsx b/apps/remix/app/components/forms/editor/editor-field-date-form.tsx index b313a261d..8abe1aec8 100644 --- a/apps/remix/app/components/forms/editor/editor-field-date-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-date-form.tsx @@ -7,6 +7,7 @@ import type { z } from 'zod'; import { DEFAULT_FIELD_FONT_SIZE, type TDateFieldMeta as DateFieldMeta, + FIELD_DEFAULT_GENERIC_ALIGN, ZDateFieldMeta, } from '@documenso/lib/types/field-meta'; import { Form } from '@documenso/ui/primitives/form/form'; @@ -39,7 +40,7 @@ export const EditorFieldDateForm = ({ mode: 'onChange', defaultValues: { fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, - textAlign: value.textAlign || 'left', + textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, }, }); diff --git a/apps/remix/app/components/forms/editor/editor-field-email-form.tsx b/apps/remix/app/components/forms/editor/editor-field-email-form.tsx index 5da10652d..c51f3c74f 100644 --- a/apps/remix/app/components/forms/editor/editor-field-email-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-email-form.tsx @@ -7,6 +7,7 @@ import type { z } from 'zod'; import { DEFAULT_FIELD_FONT_SIZE, type TEmailFieldMeta as EmailFieldMeta, + FIELD_DEFAULT_GENERIC_ALIGN, ZEmailFieldMeta, } from '@documenso/lib/types/field-meta'; import { Form } from '@documenso/ui/primitives/form/form'; @@ -39,7 +40,7 @@ export const EditorFieldEmailForm = ({ mode: 'onChange', defaultValues: { fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, - textAlign: value.textAlign || 'left', + textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, }, }); diff --git a/apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx b/apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx index 9e8fd0b7c..3ba8c434a 100644 --- a/apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx @@ -3,6 +3,10 @@ import { useEffect } from 'react'; import { Trans, useLingui } from '@lingui/react/macro'; import { type Control, useFormContext } from 'react-hook-form'; +import { FIELD_MIN_LINE_HEIGHT } from '@documenso/lib/types/field-meta'; +import { FIELD_MAX_LINE_HEIGHT } from '@documenso/lib/types/field-meta'; +import { FIELD_MIN_LETTER_SPACING } from '@documenso/lib/types/field-meta'; +import { FIELD_MAX_LETTER_SPACING } from '@documenso/lib/types/field-meta'; import { cn } from '@documenso/ui/lib/utils'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { @@ -107,6 +111,119 @@ export const EditorGenericTextAlignField = ({ ); }; +export const EditorGenericVerticalAlignField = ({ + formControl, + className, +}: { + formControl: FormControlType; + className?: string; +}) => { + const { t } = useLingui(); + + return ( + ( + + + Vertical Align + + + + + + + )} + /> + ); +}; + +export const EditorGenericLineHeightField = ({ + formControl, + className, +}: { + formControl: FormControlType; + className?: string; +}) => { + const { t } = useLingui(); + + return ( + ( + + + Line Height + + + + + + + )} + /> + ); +}; + +export const EditorGenericLetterSpacingField = ({ + formControl, + className, +}: { + formControl: FormControlType; + className?: string; +}) => { + const { t } = useLingui(); + + return ( + ( + + + Letter Spacing + + + + + + + )} + /> + ); +}; + export const EditorGenericRequiredField = ({ formControl, className, diff --git a/apps/remix/app/components/forms/editor/editor-field-initials-form.tsx b/apps/remix/app/components/forms/editor/editor-field-initials-form.tsx index 2f54c9cd4..ebc478faf 100644 --- a/apps/remix/app/components/forms/editor/editor-field-initials-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-initials-form.tsx @@ -6,6 +6,7 @@ import type { z } from 'zod'; import { DEFAULT_FIELD_FONT_SIZE, + FIELD_DEFAULT_GENERIC_ALIGN, type TInitialsFieldMeta as InitialsFieldMeta, ZInitialsFieldMeta, } from '@documenso/lib/types/field-meta'; @@ -39,7 +40,7 @@ export const EditorFieldInitialsForm = ({ mode: 'onChange', defaultValues: { fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, - textAlign: value.textAlign || 'left', + textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, }, }); diff --git a/apps/remix/app/components/forms/editor/editor-field-name-form.tsx b/apps/remix/app/components/forms/editor/editor-field-name-form.tsx index 4c57ed917..9e5849dd8 100644 --- a/apps/remix/app/components/forms/editor/editor-field-name-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-name-form.tsx @@ -6,6 +6,7 @@ import type { z } from 'zod'; import { DEFAULT_FIELD_FONT_SIZE, + FIELD_DEFAULT_GENERIC_ALIGN, type TNameFieldMeta as NameFieldMeta, ZNameFieldMeta, } from '@documenso/lib/types/field-meta'; @@ -39,7 +40,7 @@ export const EditorFieldNameForm = ({ mode: 'onChange', defaultValues: { fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, - textAlign: value.textAlign || 'left', + textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, }, }); diff --git a/apps/remix/app/components/forms/editor/editor-field-number-form.tsx b/apps/remix/app/components/forms/editor/editor-field-number-form.tsx index 0871d8e53..bc6e7ae6a 100644 --- a/apps/remix/app/components/forms/editor/editor-field-number-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-number-form.tsx @@ -6,6 +6,11 @@ import { useForm, useWatch } from 'react-hook-form'; import type { z } from 'zod'; import { + DEFAULT_FIELD_FONT_SIZE, + FIELD_DEFAULT_GENERIC_ALIGN, + FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN, + FIELD_DEFAULT_LETTER_SPACING, + FIELD_DEFAULT_LINE_HEIGHT, type TNumberFieldMeta as NumberFieldMeta, ZNumberFieldMeta, } from '@documenso/lib/types/field-meta'; @@ -31,9 +36,12 @@ import { Separator } from '@documenso/ui/primitives/separator'; import { EditorGenericFontSizeField, EditorGenericLabelField, + EditorGenericLetterSpacingField, + EditorGenericLineHeightField, EditorGenericReadOnlyField, EditorGenericRequiredField, EditorGenericTextAlignField, + EditorGenericVerticalAlignField, } from './editor-field-generic-field-forms'; const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({ @@ -43,6 +51,9 @@ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({ numberFormat: true, fontSize: true, textAlign: true, + lineHeight: true, + letterSpacing: true, + verticalAlign: true, required: true, readOnly: true, minValue: true, @@ -99,8 +110,11 @@ export const EditorFieldNumberForm = ({ placeholder: value.placeholder || '', value: value.value || '', numberFormat: value.numberFormat || null, - fontSize: value.fontSize || 14, - textAlign: value.textAlign || 'left', + fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, + textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, + lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT, + letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING, + verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN, required: value.required || false, readOnly: value.readOnly || false, minValue: value.minValue, @@ -118,6 +132,10 @@ export const EditorFieldNumberForm = ({ useEffect(() => { const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues); + if (formValues.readOnly && !formValues.value) { + void form.trigger('value'); + } + if (validatedFormValues.success) { onValueChange({ type: 'number', @@ -130,10 +148,12 @@ export const EditorFieldNumberForm = ({
-
- + +
+ +
@@ -204,6 +224,12 @@ export const EditorFieldNumberForm = ({ )} /> +
+ + + +
+
diff --git a/apps/remix/app/components/forms/editor/editor-field-signature-form.tsx b/apps/remix/app/components/forms/editor/editor-field-signature-form.tsx index bf5d6ac26..2a1064751 100644 --- a/apps/remix/app/components/forms/editor/editor-field-signature-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-signature-form.tsx @@ -5,11 +5,8 @@ import { Trans } from '@lingui/react/macro'; import { useForm, useWatch } from 'react-hook-form'; import type { z } from 'zod'; -import { - DEFAULT_FIELD_FONT_SIZE, - type TSignatureFieldMeta, - ZSignatureFieldMeta, -} from '@documenso/lib/types/field-meta'; +import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf'; +import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta'; import { Form } from '@documenso/ui/primitives/form/form'; import { EditorGenericFontSizeField } from './editor-field-generic-field-forms'; @@ -35,7 +32,7 @@ export const EditorFieldSignatureForm = ({ resolver: zodResolver(ZSignatureFieldFormSchema), mode: 'onChange', defaultValues: { - fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, + fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE, }, }); diff --git a/apps/remix/app/components/forms/editor/editor-field-text-form.tsx b/apps/remix/app/components/forms/editor/editor-field-text-form.tsx index 17432944c..7f23de986 100644 --- a/apps/remix/app/components/forms/editor/editor-field-text-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -3,11 +3,16 @@ import { useEffect } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import type { z } from 'zod'; import { DEFAULT_FIELD_FONT_SIZE, + FIELD_DEFAULT_GENERIC_ALIGN, + FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN, + FIELD_DEFAULT_LETTER_SPACING, + FIELD_DEFAULT_LINE_HEIGHT, type TTextFieldMeta as TextFieldMeta, + ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; import { Form, @@ -22,32 +27,36 @@ import { Textarea } from '@documenso/ui/primitives/textarea'; import { EditorGenericFontSizeField, + EditorGenericLetterSpacingField, + EditorGenericLineHeightField, EditorGenericReadOnlyField, EditorGenericRequiredField, EditorGenericTextAlignField, + EditorGenericVerticalAlignField, } from './editor-field-generic-field-forms'; -const ZTextFieldFormSchema = z - .object({ - label: z.string().optional(), - placeholder: z.string().optional(), - text: z.string().optional(), - characterLimit: z.coerce.number().min(0).optional(), - fontSize: z.coerce.number().min(8).max(96).optional(), - textAlign: z.enum(['left', 'center', 'right']).optional(), - required: z.boolean().optional(), - readOnly: z.boolean().optional(), - }) - .refine( - (data) => { - // A read-only field must have text - return !data.readOnly || (data.text && data.text.length > 0); - }, - { - message: 'A read-only field must have text', - path: ['text'], - }, - ); +const ZTextFieldFormSchema = ZTextFieldMeta.pick({ + label: true, + placeholder: true, + text: true, + characterLimit: true, + fontSize: true, + textAlign: true, + lineHeight: true, + letterSpacing: true, + verticalAlign: true, + required: true, + readOnly: true, +}).refine( + (data) => { + // A read-only field must have text + return !data.readOnly || (data.text && data.text.length > 0); + }, + { + message: 'A read-only field must have text', + path: ['text'], + }, +); type TTextFieldFormSchema = z.infer; @@ -73,7 +82,10 @@ export const EditorFieldTextForm = ({ text: value.text || '', characterLimit: value.characterLimit || 0, fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, - textAlign: value.textAlign || 'left', + textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN, + lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT, + letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING, + verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN, required: value.required || false, readOnly: value.readOnly || false, }, @@ -89,6 +101,10 @@ export const EditorFieldTextForm = ({ useEffect(() => { const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues); + if (formValues.readOnly && !formValues.text) { + void form.trigger('text'); + } + if (validatedFormValues.success) { onValueChange({ type: 'text', @@ -101,10 +117,12 @@ export const EditorFieldTextForm = ({
-
- + +
+ +
{ - field.onChange(e); - const values = form.getValues(); const characterLimit = parseInt(e.target.value, 10) || 0; + field.onChange(characterLimit || ''); + const textValue = values.text || ''; if (characterLimit > 0 && textValue.length > characterLimit) { @@ -206,6 +223,12 @@ export const EditorFieldTextForm = ({ )} /> +
+ + + +
+
diff --git a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx index d5ff98f75..1f0c7e89e 100644 --- a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx +++ b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Trans } from '@lingui/react/macro'; -import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client'; +import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client'; import { DownloadIcon } from 'lucide-react'; import { DateTime } from 'luxon'; @@ -100,7 +100,14 @@ export const DocumentCertificateQRView = ({ )} {internalVersion === 2 ? ( - + + @@ -189,7 +196,7 @@ const DocumentCertificateQrV2 = ({ envelopeItems={envelopeItems} token={token} trigger={ - diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index 1232573ac..c884fdba9 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -2,7 +2,7 @@ import { lazy, useEffect, useMemo, useState } from 'react'; import { faker } from '@faker-js/faker/locale/en'; import { Trans } from '@lingui/react/macro'; -import { FieldType } from '@prisma/client'; +import { FieldType, SigningStatus } from '@prisma/client'; import { FileTextIcon } from 'lucide-react'; import { match } from 'ts-pattern'; @@ -201,7 +201,10 @@ export const EnvelopeEditorPreviewPage = () => { envelope={envelope} token={undefined} fields={fieldsWithPlaceholders} - recipients={envelope.recipients} + recipients={envelope.recipients.map((recipient) => ({ + ...recipient, + signingStatus: SigningStatus.SIGNED, + }))} overrideSettings={{ mode: 'export', }} diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index c13ca0d66..5fa911955 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -49,7 +49,7 @@ export const EnvelopeEditorUploadPage = () => { const organisation = useCurrentOrganisation(); const { t } = useLingui(); - const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor(); + const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor(); const { maximumEnvelopeItemCount, remaining } = useLimits(); const { toast } = useToast(); @@ -165,9 +165,17 @@ export const EnvelopeEditorUploadPage = () => { const onFileDelete = (envelopeItemId: string) => { setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId)); + const fieldsWithoutDeletedItem = envelope.fields.filter( + (field) => field.envelopeItemId !== envelopeItemId, + ); + setLocalEnvelope({ envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId), + fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId), }); + + // Reset editor fields. + editorFields.resetForm(fieldsWithoutDeletedItem); }; /** diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index 48f13127e..2902eab76 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; import { useLingui } from '@lingui/react/macro'; -import { type Recipient, SigningStatus } from '@prisma/client'; +import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client'; import type Konva from 'konva'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; @@ -19,6 +19,7 @@ export default function EnvelopeGenericPageRenderer() { const { i18n } = useLingui(); const { + envelopeStatus, currentEnvelopeItem, fields, recipients, @@ -42,6 +43,10 @@ export default function EnvelopeGenericPageRenderer() { const { _className, scale } = pageContext; const localPageFields = useMemo((): GenericLocalField[] => { + if (envelopeStatus === DocumentStatus.COMPLETED) { + return []; + } + return fields .filter( (field) => @@ -54,11 +59,20 @@ export default function EnvelopeGenericPageRenderer() { throw new Error(`Recipient not found for field ${field.id}`); } + const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted; + return { ...field, + inserted: isInserted, + customText: isInserted ? field.customText : '', recipient, }; - }); + }) + .filter( + ({ inserted, fieldMeta, recipient }) => + (recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) || + fieldMeta?.readOnly, + ); }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { @@ -67,12 +81,8 @@ export default function EnvelopeGenericPageRenderer() { return; } - const { recipient } = field; - const fieldTranslations = getClientSideFieldTranslations(i18n); - const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted; - renderField({ scale, pageLayer: pageLayer.current, @@ -83,7 +93,6 @@ export default function EnvelopeGenericPageRenderer() { height: Number(field.height), positionX: Number(field.positionX), positionY: Number(field.positionY), - customText: isInserted ? field.customText : '', fieldMeta: field.fieldMeta, signature: { signatureImageAsBase64: '', diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx index 3240f091b..5e457e36a 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx @@ -1,7 +1,14 @@ import { useEffect, useMemo } from 'react'; import { Trans, useLingui } from '@lingui/react/macro'; -import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client'; +import { + type Field, + FieldType, + type Recipient, + RecipientRole, + type Signature, + SigningStatus, +} from '@prisma/client'; import type Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import { match } from 'ts-pattern'; @@ -12,6 +19,7 @@ import { useOptionalSession } from '@documenso/lib/client-only/providers/session import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { isBase64Image } from '@documenso/lib/constants/signatures'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import type { TEnvelope } from '@documenso/lib/types/envelope'; 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'; @@ -19,6 +27,7 @@ import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields 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 { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip'; 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'; @@ -36,6 +45,10 @@ 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'; +type GenericLocalField = TEnvelope['fields'][number] & { + recipient: Pick; +}; + export default function EnvelopeSignerPageRenderer() { const { t, i18n } = useLingui(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); @@ -91,6 +104,36 @@ export default function EnvelopeSignerPageRenderer() { ); }, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]); + /** + * Returns fields that have been fully signed by other recipients for this specific + * page. + */ + const localPageOtherRecipientFields = useMemo((): GenericLocalField[] => { + const signedRecipients = envelope.recipients.filter( + (recipient) => recipient.signingStatus === SigningStatus.SIGNED, + ); + + return signedRecipients.flatMap((recipient) => { + return recipient.fields + .filter( + (field) => + field.page === pageContext.pageNumber && + field.envelopeItemId === currentEnvelopeItem?.id && + (field.inserted || field.fieldMeta?.readOnly), + ) + .map((field) => ({ + ...field, + recipient: { + id: recipient.id, + name: recipient.name, + email: recipient.email, + signingStatus: recipient.signingStatus, + role: recipient.role, + }, + })); + }); + }, [envelope.recipients, pageContext.pageNumber]); + const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { if (!pageLayer.current) { console.error('Layer not loaded yet'); @@ -376,6 +419,46 @@ export default function EnvelopeSignerPageRenderer() { } }; + const renderFields = () => { + if (!pageLayer.current) { + console.error('Layer not loaded yet'); + return; + } + + // Render current recipient fields. + for (const field of localPageFields) { + renderFieldOnLayer(field); + } + + // Render other recipient signed and inserted fields. + for (const field of localPageOtherRecipientFields) { + try { + renderField({ + scale, + pageLayer: pageLayer.current, + field: { + renderId: field.id.toString(), + ...field, + width: Number(field.width), + height: Number(field.height), + positionX: Number(field.positionX), + positionY: Number(field.positionY), + fieldMeta: field.fieldMeta, + }, + translations: getClientSideFieldTranslations(i18n), + pageWidth: unscaledViewport.width, + pageHeight: unscaledViewport.height, + color: 'readOnly', + editable: false, + mode: 'sign', + }); + } catch (err) { + console.error('Unable to render one or more fields belonging to other recipients.'); + console.error(err); + } + } + }; + const signField = async ( fieldId: number, payload: TSignEnvelopeFieldValue, @@ -412,11 +495,7 @@ export default function EnvelopeSignerPageRenderer() { * 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); - } - + renderFields(); currentPageLayer.batchDraw(); }; @@ -428,9 +507,7 @@ export default function EnvelopeSignerPageRenderer() { return; } - localPageFields.forEach((field) => { - renderFieldOnLayer(field); - }); + renderFields(); pageLayer.current.batchDraw(); }, [localPageFields, showPendingFieldTooltip, fullName, signature, email]); @@ -446,9 +523,7 @@ export default function EnvelopeSignerPageRenderer() { // Rerender the whole page. pageLayer.current.destroyChildren(); - localPageFields.forEach((field) => { - renderFieldOnLayer(field); - }); + renderFields(); pageLayer.current.batchDraw(); }, [selectedAssistantRecipient]); @@ -475,6 +550,15 @@ export default function EnvelopeSignerPageRenderer() { )} + {localPageOtherRecipientFields.map((field) => ( + + ))} + {/* The element Konva will inject it's canvas into. */}
diff --git a/assets/field-font-alignment.pdf b/assets/field-font-alignment.pdf index 35dad9311..a254705ba 100644 Binary files a/assets/field-font-alignment.pdf and b/assets/field-font-alignment.pdf differ diff --git a/packages/app-tests/constants/field-alignment-pdf.ts b/packages/app-tests/constants/field-alignment-pdf.ts index 7f42d9ef5..a5539007f 100644 --- a/packages/app-tests/constants/field-alignment-pdf.ts +++ b/packages/app-tests/constants/field-alignment-pdf.ts @@ -1,4 +1,6 @@ import { FieldType } from '@prisma/client'; +import fs from 'node:fs'; +import path from 'node:path'; import type { TFieldAndMeta } from '@documenso/lib/types/field-meta'; import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; @@ -13,11 +15,66 @@ export type FieldTestData = TFieldAndMeta & { signature?: string; }; -const columnWidth = 19.125; -const rowHeight = 6.7; +export const signatureBase64Demo = `data:image/png;base64,${fs.readFileSync( + path.join(__dirname, '../../../packages/assets/', 'logo_icon.png'), + 'base64', +)}`; -const alignmentGridStartX = 31; -const alignmentGridStartY = 19.02; +const columnWidth = 19.125; +const fullColumnWidth = 57.37499999999998; +const rowHeight = 6.7; +const rowPadding = 0; + +const calculatePositionPageOne = ( + row: number, + column: number, + width: 'full' | 'column' = 'column', +) => { + const alignmentGridStartX = 31; + const alignmentGridStartY = 19; + + return { + height: rowHeight, + width: width === 'full' ? fullColumnWidth : columnWidth, + positionX: alignmentGridStartX + (column ?? 0) * columnWidth, + positionY: alignmentGridStartY + row * (rowHeight + rowPadding), + }; +}; + +const calculatePositionPageTwo = ( + row: number, + column: number, + width: 'full' | 'column' = 'column', +) => { + const alignmentGridStartX = 31; + const alignmentGridStartY = 16.35; + + return { + height: rowHeight, + width: width === 'full' ? fullColumnWidth : columnWidth, + positionX: alignmentGridStartX + (column ?? 0) * columnWidth, + positionY: alignmentGridStartY + row * (rowHeight + rowPadding), + }; +}; + +const calculatePositionPageThree = ( + row: number, + column: number, + width: 'full' | 'column' = 'column', + rowQuantity: number = 1, +) => { + const alignmentGridStartX = 31; + const alignmentGridStartY = 16.4; + + const rowHeight = 6.8; + + return { + height: rowHeight * rowQuantity, + width: width === 'full' ? fullColumnWidth : columnWidth, + positionX: alignmentGridStartX + (column ?? 0) * columnWidth, + positionY: alignmentGridStartY + row * (rowHeight + rowPadding), + }; +}; export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ /** @@ -31,10 +88,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'email', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(0, 0), customText: 'admin@documenso.com', }, { @@ -44,10 +98,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'email', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(0, 1), customText: 'admin@documenso.com', }, { @@ -58,10 +109,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'email', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(0, 2), customText: 'admin@documenso.com', }, /** @@ -75,10 +123,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'name', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(1, 0), customText: 'John Doe', }, { @@ -88,10 +133,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'name', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(1, 1), customText: 'John Doe', }, { @@ -102,10 +144,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'name', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(1, 2), customText: 'John Doe', }, /** @@ -119,10 +158,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'date', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(2, 0), customText: '123456789', }, { @@ -132,10 +168,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'date', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(2, 1), customText: '123456789', }, { @@ -146,10 +179,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'date', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(2, 2), customText: '123456789', }, /** @@ -163,10 +193,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'text', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(3, 0), customText: '123456789', }, { @@ -176,10 +203,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'text', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(3, 1), customText: '123456789', }, { @@ -190,10 +214,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'text', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(3, 2), customText: '123456789', }, /** @@ -207,10 +228,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'number', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(4, 0), customText: '123456789', }, { @@ -220,10 +238,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'number', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(4, 1), customText: '123456789', }, { @@ -234,10 +249,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'number', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(4, 2), customText: '123456789', }, /** @@ -251,10 +263,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'initials', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(5, 0), customText: 'JD', }, { @@ -264,10 +273,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'initials', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(5, 1), customText: 'JD', }, { @@ -278,10 +284,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'initials', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(5, 2), customText: 'JD', }, /** @@ -299,10 +302,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ ], }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(6, 0), customText: '0', }, { @@ -312,15 +312,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'radio', values: [ { id: 1, checked: false, value: 'Option 1' }, - { id: 2, checked: true, value: 'Option 2' }, + { id: 2, checked: false, value: 'Option 2' }, ], }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, - customText: '2', + ...calculatePositionPageOne(6, 1), + customText: '', }, { type: FieldType.RADIO, @@ -330,15 +327,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'radio', values: [ { id: 1, checked: false, value: 'Option 1' }, - { id: 2, checked: false, value: 'Option 2' }, + { id: 2, checked: true, value: 'Option 2' }, ], }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, - customText: '', + ...calculatePositionPageOne(6, 2), + customText: '1', }, /** * Row 8 Checkbox @@ -355,10 +349,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ ], }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(7, 0), customText: toCheckboxCustomText([0]), }, { @@ -368,15 +359,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'checkbox', values: [ { id: 1, checked: false, value: 'Option 1' }, - { id: 2, checked: true, value: 'Option 2' }, + { id: 2, checked: false, value: 'Option 2' }, ], }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, - customText: toCheckboxCustomText([1]), + ...calculatePositionPageOne(7, 1), + customText: '', }, { type: FieldType.CHECKBOX, @@ -386,15 +374,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'checkbox', values: [ { id: 1, checked: false, value: 'Option 1' }, - { id: 2, checked: false, value: 'Option 2' }, + { id: 2, checked: true, value: 'Option 2' }, ], }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, - customText: '', + ...calculatePositionPageOne(7, 2), + customText: toCheckboxCustomText([1]), }, /** * Row 8 Dropdown @@ -407,10 +392,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'dropdown', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(8, 0), customText: 'Option 1', }, { @@ -420,10 +402,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'dropdown', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(8, 1), customText: 'Option 1', }, { @@ -434,10 +413,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'dropdown', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(8, 2), customText: 'Option 1', }, /** @@ -450,10 +426,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'signature', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(9, 0), customText: '', signature: 'My Signature', }, @@ -463,10 +436,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'signature', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(9, 1), customText: '', signature: 'My Signature', }, @@ -477,22 +447,295 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [ type: 'signature', }, page: 1, - height: rowHeight, - width: columnWidth, - positionX: 0, - positionY: 0, + ...calculatePositionPageOne(9, 2), customText: '', signature: 'My Signature', }, + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 2 + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + // TEXT GRID ROW 1 + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'left', + type: 'text', + verticalAlign: 'top', + }, + page: 2, + ...calculatePositionPageTwo(0, 0), + customText: 'SOME TEXT', + }, + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'center', + type: 'text', + verticalAlign: 'top', + }, + page: 2, + ...calculatePositionPageTwo(0, 1), + customText: 'SOME TEXT', + }, + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'right', + type: 'text', + verticalAlign: 'top', + }, + page: 2, + ...calculatePositionPageTwo(0, 2), + customText: 'SOME TEXT', + }, + // TEXT GRID ROW 2 + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'left', + type: 'text', + verticalAlign: 'middle', + }, + page: 2, + ...calculatePositionPageTwo(1, 0), + customText: 'SOME TEXT', + }, + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'center', + type: 'text', + verticalAlign: 'middle', + }, + page: 2, + ...calculatePositionPageTwo(1, 1), + customText: 'SOME TEXT', + }, + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'right', + type: 'text', + verticalAlign: 'middle', + }, + page: 2, + ...calculatePositionPageTwo(1, 2), + customText: 'SOME TEXT', + }, + // TEXT GRID ROW 3 + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'left', + type: 'text', + verticalAlign: 'bottom', + }, + page: 2, + ...calculatePositionPageTwo(2, 0), + customText: 'SOME TEXT', + }, + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'center', + type: 'text', + verticalAlign: 'bottom', + }, + page: 2, + ...calculatePositionPageTwo(2, 1), + customText: 'SOME TEXT', + }, + { + type: FieldType.TEXT, + fieldMeta: { + textAlign: 'right', + type: 'text', + verticalAlign: 'bottom', + }, + page: 2, + ...calculatePositionPageTwo(2, 2), + customText: 'SOME TEXT', + }, + // NUMBER GRID ROW 1 + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'left', + type: 'number', + verticalAlign: 'top', + }, + page: 2, + ...calculatePositionPageTwo(3, 0), + customText: '123456789123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'center', + type: 'number', + verticalAlign: 'top', + }, + page: 2, + ...calculatePositionPageTwo(3, 1), + customText: '123456789123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'right', + type: 'number', + verticalAlign: 'top', + }, + page: 2, + ...calculatePositionPageTwo(3, 2), + customText: '123456789123456789', + }, + // NUMBER GRID ROW 2 + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'left', + type: 'number', + verticalAlign: 'middle', + }, + page: 2, + ...calculatePositionPageTwo(4, 0), + customText: '123456789123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'center', + type: 'number', + verticalAlign: 'middle', + }, + page: 2, + ...calculatePositionPageTwo(4, 1), + customText: '123456789123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'right', + type: 'number', + verticalAlign: 'middle', + }, + page: 2, + ...calculatePositionPageTwo(4, 2), + customText: '123456789123456789', + }, + // NUMBER GRID ROW 3 + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'left', + type: 'number', + verticalAlign: 'bottom', + }, + page: 2, + ...calculatePositionPageTwo(5, 0), + customText: '123456789123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'center', + type: 'number', + verticalAlign: 'bottom', + }, + page: 2, + ...calculatePositionPageTwo(5, 1), + customText: '123456789123456789', + }, + { + type: FieldType.NUMBER, + fieldMeta: { + textAlign: 'right', + type: 'number', + verticalAlign: 'bottom', + }, + page: 2, + ...calculatePositionPageTwo(5, 2), + customText: '123456789123456789', + }, + // Text combing + { + type: FieldType.TEXT, + fieldMeta: { + type: 'text', + verticalAlign: 'middle', + letterSpacing: 32, + characterLimit: 9, + }, + page: 2, + ...calculatePositionPageTwo(6, 0, 'full'), + positionX: calculatePositionPageTwo(6, 0, 'full').positionX + 1.75, + width: calculatePositionPageTwo(6, 0, 'full').width + 1.75, + customText: 'HEY HEY 1', + }, + // Number combing + { + type: FieldType.NUMBER, + fieldMeta: { + type: 'number', + verticalAlign: 'middle', + letterSpacing: 32, + }, + page: 2, + ...calculatePositionPageTwo(7, 0, 'full'), + positionX: calculatePositionPageTwo(7, 0, 'full').positionX + 1.75, + width: calculatePositionPageTwo(7, 0, 'full').width + 1.75, + + customText: '123456789', + }, + /** + * @@@@@@@@@@@@@@@@@@@@@@@ + * + * PAGE 2 TEXT MULTILINE + * + * @@@@@@@@@@@@@@@@@@@@@@@ + */ + { + type: FieldType.TEXT, + fieldMeta: { + verticalAlign: 'top', + textAlign: 'left', + lineHeight: 2.24, + type: 'text', + }, + page: 3, + ...calculatePositionPageThree(0, 0, 'full', 3), + customText: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + }, + { + type: FieldType.TEXT, + fieldMeta: { + verticalAlign: 'middle', + textAlign: 'center', + lineHeight: 2.24, + type: 'text', + }, + page: 3, + ...calculatePositionPageThree(3, 0, 'full', 3), + customText: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + { + type: FieldType.TEXT, + fieldMeta: { + verticalAlign: 'bottom', + textAlign: 'right', + lineHeight: 2.24, + type: 'text', + }, + page: 3, + ...calculatePositionPageThree(6, 0, 'full', 3), + customText: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, ] as const; - -export const formatAlignmentTestFields = ALIGNMENT_TEST_FIELDS.map((field, index) => { - const row = Math.floor(index / 3); - const column = index % 3; - - return { - ...field, - positionX: alignmentGridStartX + column * columnWidth, - positionY: alignmentGridStartY + row * rowHeight, - }; -}); diff --git a/packages/app-tests/constants/field-meta-pdf.ts b/packages/app-tests/constants/field-meta-pdf.ts index 36cb79590..ba4f63b33 100644 --- a/packages/app-tests/constants/field-meta-pdf.ts +++ b/packages/app-tests/constants/field-meta-pdf.ts @@ -7,6 +7,7 @@ import { } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants'; import type { FieldTestData } from './field-alignment-pdf'; +import { signatureBase64Demo } from './field-alignment-pdf'; const columnWidth = 20.1; const fullColumnWidth = 75.8; @@ -37,7 +38,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ page: 2, ...calculatePosition(0, 0), customText: '', - signature: 'My Signature', + signature: signatureBase64Demo, }, { type: FieldType.SIGNATURE, @@ -47,7 +48,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ page: 2, ...calculatePosition(1, 0), customText: '', - signature: 'My Signature', + signature: signatureBase64Demo, }, { type: FieldType.SIGNATURE, @@ -67,7 +68,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ page: 2, ...calculatePosition(3, 0), customText: '', - signature: 'My Signature', + signature: 'My Signature super overflow maybe', }, /** @@ -80,7 +81,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 3, ...calculatePosition(0, 0, 'full'), - customText: '123456789', + customText: 'Hello world, this is some random text that I have written here', }, { type: FieldType.TEXT, @@ -89,7 +90,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 3, ...calculatePosition(1, 0), - customText: '123456789123456789123456789123456789', + customText: 'Some text that should overflow correctly', }, { type: FieldType.TEXT, @@ -109,7 +110,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 3, ...calculatePosition(3, 0), - customText: '123456789', + customText: 'Input should have a placeholder text when clicked', }, { type: FieldType.TEXT, @@ -119,7 +120,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 3, ...calculatePosition(3, 1), - customText: '123456789', + customText: 'Should have a label during editing and signing', }, { type: FieldType.TEXT, @@ -129,7 +130,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 3, ...calculatePosition(3, 2), - customText: '123456789', + customText: '', }, { type: FieldType.TEXT, @@ -139,20 +140,19 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 3, ...calculatePosition(4, 0), - customText: '123456789', + customText: 'This is a required field', }, { type: FieldType.TEXT, fieldMeta: { type: 'text', readOnly: true, - text: 'Readonly Value', + text: 'Some Readonly Value', }, page: 3, ...calculatePosition(4, 1), - customText: 'Readonly Value', + customText: '', }, - /** * PAGE 4 NUMBER */ @@ -241,10 +241,11 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ fieldMeta: { type: 'number', readOnly: true, + value: '123456789', }, page: 4, ...calculatePosition(4, 1), - customText: '123456789', + customText: '', }, /** @@ -285,6 +286,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ fieldMeta: { direction: 'vertical', type: 'radio', + required: true, values: [ { id: 1, checked: false, value: 'Option 1' }, { id: 2, checked: false, value: 'Option 2' }, @@ -293,17 +295,18 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 5, ...calculatePosition(2, 0), - customText: '', + customText: '2', }, { type: FieldType.RADIO, fieldMeta: { direction: 'vertical', type: 'radio', + readOnly: true, values: [ { id: 1, checked: false, value: 'Option 1' }, { id: 2, checked: false, value: 'Option 2' }, - { id: 3, checked: false, value: 'Option 3' }, + { id: 3, checked: true, value: 'Option 3' }, ], }, page: 5, @@ -358,7 +361,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 6, ...calculatePosition(2, 0), - customText: '', + customText: toCheckboxCustomText([2]), }, { type: FieldType.CHECKBOX, @@ -368,7 +371,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ readOnly: true, values: [ { id: 1, checked: false, value: 'Option 1' }, - { id: 2, checked: false, value: 'Option 2' }, + { id: 2, checked: true, value: 'Option 2' }, ], }, page: 6, @@ -445,11 +448,11 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ fieldMeta: { values: [{ value: 'Option 1' }, { value: 'Option 2' }], type: 'dropdown', - defaultValue: 'Option 1', + defaultValue: 'Option 2', }, page: 7, ...calculatePosition(1, 0), - customText: 'Option 1', + customText: 'Option 2', }, { type: FieldType.DROPDOWN, @@ -460,13 +463,14 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [ }, page: 7, ...calculatePosition(2, 0), - customText: 'Option 1', + customText: 'Option 3', }, { type: FieldType.DROPDOWN, fieldMeta: { values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }], type: 'dropdown', + defaultValue: 'Option 1', readOnly: true, }, page: 7, diff --git a/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts index 8e759efd7..5493d1ff7 100644 --- a/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts +++ b/packages/app-tests/e2e/api/v2/envelopes-api.spec.ts @@ -27,7 +27,7 @@ import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/en import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types'; -import { formatAlignmentTestFields } from '../../../constants/field-alignment-pdf'; +import { ALIGNMENT_TEST_FIELDS } from '../../../constants/field-alignment-pdf'; import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf'; const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); @@ -490,7 +490,7 @@ test.describe('API V2 Envelopes', () => { // Step 6: Create fields for first PDF (alignment fields) const alignmentFieldsRequest = { envelopeId: createdEnvelope.id, - data: formatAlignmentTestFields.map((field) => ({ + data: ALIGNMENT_TEST_FIELDS.map((field) => ({ recipientId, envelopeItemId: alignmentItem.id, type: field.type, @@ -547,7 +547,7 @@ test.describe('API V2 Envelopes', () => { expect(finalEnvelope.envelopeItems.length).toBe(2); expect(finalEnvelope.recipients.length).toBe(1); expect(finalEnvelope.fields.length).toBe( - formatAlignmentTestFields.length + FIELD_META_TEST_FIELDS.length, + ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length, ); expect(finalEnvelope.title).toBe('Envelope Full Field Test'); expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT); diff --git a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts index 375a3c2ae..2125719d5 100644 --- a/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-alignment.spec.ts @@ -34,7 +34,34 @@ import { apiSignin } from '../fixtures/authentication'; test.describe.configure({ mode: 'parallel', timeout: 60000 }); -test.skip('field placement visual regression', async ({ page }, testInfo) => { +test.skip('seed alignment test document', async ({ page }) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + email: 'example@documenso.com', + }, + include: { + ownedOrganisations: { + include: { + teams: true, + }, + }, + }, + }); + + const userId = user.id; + const teamId = user.ownedOrganisations[0].teams[0].id; + + await seedAlignmentTestDocument({ + userId, + teamId, + recipientName: user.name || '', + recipientEmail: user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + }); +}); + +test('field placement visual regression', async ({ page }, testInfo) => { const { user, team } = await seedUser(); const envelope = await seedAlignmentTestDocument({ @@ -289,7 +316,7 @@ const compareSignedPdfWithImages = async ({ // Expect the certificate to NOT be blank. Since the storedImage is blank. expect.soft(comparison).toBeGreaterThan(20000); } else { - expect.soft(comparison).toEqual(0); + expect.soft(comparison).toBeLessThan(2); } } }; diff --git a/packages/app-tests/visual-regression/alignment-pdf-0.png b/packages/app-tests/visual-regression/alignment-pdf-0.png new file mode 100644 index 000000000..dbc4ec710 Binary files /dev/null and b/packages/app-tests/visual-regression/alignment-pdf-0.png differ diff --git a/packages/app-tests/visual-regression/alignment-pdf-1.png b/packages/app-tests/visual-regression/alignment-pdf-1.png new file mode 100644 index 000000000..257291012 Binary files /dev/null and b/packages/app-tests/visual-regression/alignment-pdf-1.png differ diff --git a/packages/app-tests/visual-regression/alignment-pdf-2.png b/packages/app-tests/visual-regression/alignment-pdf-2.png new file mode 100644 index 000000000..a63a6daa1 Binary files /dev/null and b/packages/app-tests/visual-regression/alignment-pdf-2.png differ diff --git a/packages/app-tests/visual-regression/alignment-pdf-3.png b/packages/app-tests/visual-regression/alignment-pdf-3.png new file mode 100644 index 000000000..b3849f9c4 Binary files /dev/null and b/packages/app-tests/visual-regression/alignment-pdf-3.png differ diff --git a/packages/app-tests/visual-regression/blank-certificate.png b/packages/app-tests/visual-regression/blank-certificate.png new file mode 100644 index 000000000..390501365 Binary files /dev/null and b/packages/app-tests/visual-regression/blank-certificate.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-0.png b/packages/app-tests/visual-regression/field-meta-pdf-0.png new file mode 100644 index 000000000..d14125e79 Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-0.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-1.png b/packages/app-tests/visual-regression/field-meta-pdf-1.png new file mode 100644 index 000000000..a0200ed8a Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-1.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-2.png b/packages/app-tests/visual-regression/field-meta-pdf-2.png new file mode 100644 index 000000000..a7bebf23b Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-2.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-3.png b/packages/app-tests/visual-regression/field-meta-pdf-3.png new file mode 100644 index 000000000..b1652549c Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-3.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-4.png b/packages/app-tests/visual-regression/field-meta-pdf-4.png new file mode 100644 index 000000000..1ae67dc1d Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-4.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-5.png b/packages/app-tests/visual-regression/field-meta-pdf-5.png new file mode 100644 index 000000000..697b87e8b Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-5.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-6.png b/packages/app-tests/visual-regression/field-meta-pdf-6.png new file mode 100644 index 000000000..abab3698a Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-6.png differ diff --git a/packages/app-tests/visual-regression/field-meta-pdf-7.png b/packages/app-tests/visual-regression/field-meta-pdf-7.png new file mode 100644 index 000000000..b3849f9c4 Binary files /dev/null and b/packages/app-tests/visual-regression/field-meta-pdf-7.png differ diff --git a/packages/lib/client-only/hooks/use-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts index 14b56a882..921699f2b 100644 --- a/packages/lib/client-only/hooks/use-editor-fields.ts +++ b/packages/lib/client-only/hooks/use-editor-fields.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import type { Recipient } from '@prisma/client'; +import type { Field, Recipient } from '@prisma/client'; import { FieldType } from '@prisma/client'; import { useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -63,6 +63,8 @@ type UseEditorFieldsResponse = { // Selected recipient selectedRecipient: Recipient | null; setSelectedRecipient: (recipientId: number | null) => void; + + resetForm: (fields?: Field[]) => void; }; export const useEditorFields = ({ @@ -72,24 +74,30 @@ export const useEditorFields = ({ const [selectedFieldFormId, setSelectedFieldFormId] = useState(null); const [selectedRecipientId, setSelectedRecipientId] = useState(null); + const generateDefaultValues = (fields?: Field[]) => { + const formFields = (fields || envelope.fields).map( + (field): TLocalField => ({ + id: field.id, + formId: nanoid(), + envelopeItemId: field.envelopeItemId, + page: field.page, + type: field.type, + positionX: Number(field.positionX), + positionY: Number(field.positionY), + width: Number(field.width), + height: Number(field.height), + recipientId: field.recipientId, + fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, + }), + ); + + return { + fields: formFields, + }; + }; + const form = useForm({ - defaultValues: { - fields: envelope.fields.map( - (field): TLocalField => ({ - id: field.id, - formId: nanoid(), - envelopeItemId: field.envelopeItemId, - page: field.page, - type: field.type, - positionX: Number(field.positionX), - positionY: Number(field.positionY), - width: Number(field.width), - height: Number(field.height), - recipientId: field.recipientId, - fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, - }), - ), - }, + defaultValues: generateDefaultValues(), resolver: zodResolver(ZEditorFieldsFormSchema), }); @@ -272,6 +280,10 @@ export const useEditorFields = ({ setSelectedRecipientId(foundRecipient?.id ?? null); }; + const resetForm = (fields?: Field[]) => { + form.reset(generateDefaultValues(fields)); + }; + return { // Core state localFields, @@ -295,6 +307,8 @@ export const useEditorFields = ({ // Selected recipient selectedRecipient, setSelectedRecipient, + + resetForm, }; }; diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx index 0bde6a978..d9fb64cb6 100644 --- a/packages/lib/client-only/providers/envelope-render-provider.tsx +++ b/packages/lib/client-only/providers/envelope-render-provider.tsx @@ -30,6 +30,8 @@ type EnvelopeRenderItem = TEnvelope['envelopeItems'][number]; type EnvelopeRenderProviderValue = { getPdfBuffer: (envelopeItemId: string) => FileData | null; envelopeItems: EnvelopeRenderItem[]; + envelopeStatus: TEnvelope['status']; + envelopeType: TEnvelope['type']; currentEnvelopeItem: EnvelopeRenderItem | null; setCurrentEnvelopeItem: (envelopeItemId: string) => void; fields: Field[]; @@ -44,7 +46,7 @@ type EnvelopeRenderProviderValue = { interface EnvelopeRenderProviderProps { children: React.ReactNode; - envelope: Pick; + envelope: Pick; /** * Optional fields which are passed down to renderers for custom rendering needs. @@ -100,7 +102,7 @@ export const EnvelopeRenderProvider = ({ // Indexed by documentDataId. const [files, setFiles] = useState>({}); - const [currentItem, setItem] = useState(null); + const [currentItem, setCurrentItem] = useState(null); const [renderError, setRenderError] = useState(false); @@ -163,11 +165,15 @@ export const EnvelopeRenderProvider = ({ const setCurrentEnvelopeItem = (envelopeItemId: string) => { const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId); - setItem(foundItem ?? null); + setCurrentItem(foundItem ?? null); }; // Set the selected item to the first item if none is set. useEffect(() => { + if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) { + setCurrentItem(null); + } + if (!currentItem && envelopeItems.length > 0) { setCurrentEnvelopeItem(envelopeItems[0].id); } @@ -203,6 +209,8 @@ export const EnvelopeRenderProvider = ({ value={{ getPdfBuffer, envelopeItems, + envelopeStatus: envelope.status, + envelopeType: envelope.type, currentEnvelopeItem: currentItem, setCurrentEnvelopeItem, fields: fields ?? [], diff --git a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts index c8ab1ab19..a66051ad7 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts @@ -11,7 +11,7 @@ import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAuthMethods } from '../../types/document-auth'; -import { ZFieldSchema } from '../../types/field'; +import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field'; import { ZRecipientLiteSchema } from '../../types/recipient'; import { isRecipientAuthorized } from '../document/is-recipient-authorized'; import { getTeamSettings } from '../team/get-team-settings'; @@ -63,9 +63,11 @@ export const ZEnvelopeForSigningResponse = z.object({ rejectionReason: true, }) .extend({ - fields: ZFieldSchema.omit({ - documentId: true, - templateId: true, + fields: ZEnvelopeFieldSchema.extend({ + signature: SignatureSchema.pick({ + signatureImageAsBase64: true, + typedSignature: true, + }).nullish(), }).array(), }) .array(), diff --git a/packages/lib/server-only/organisation/accept-organisation-invitation.ts b/packages/lib/server-only/organisation/accept-organisation-invitation.ts index 26133ba89..717b81034 100644 --- a/packages/lib/server-only/organisation/accept-organisation-invitation.ts +++ b/packages/lib/server-only/organisation/accept-organisation-invitation.ts @@ -88,11 +88,13 @@ export const addUserToOrganisation = async ({ organisationId, organisationGroups, organisationMemberRole, + bypassEmail = false, }: { userId: number; organisationId: string; organisationGroups: OrganisationGroup[]; organisationMemberRole: OrganisationMemberRole; + bypassEmail?: boolean; }) => { const organisationGroupToUse = organisationGroups.find( (group) => @@ -122,13 +124,15 @@ export const addUserToOrganisation = async ({ }, }); - await jobs.triggerJob({ - name: 'send.organisation-member-joined.email', - payload: { - organisationId, - memberUserId: userId, - }, - }); + if (!bypassEmail) { + await jobs.triggerJob({ + name: 'send.organisation-member-joined.email', + payload: { + organisationId, + memberUserId: userId, + }, + }); + } }, { timeout: 30_000 }, ); diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf-v2.ts b/packages/lib/server-only/pdf/insert-field-in-pdf-v2.ts index 7b61f1a93..822b643c4 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf-v2.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf-v2.ts @@ -32,10 +32,8 @@ export const insertFieldInPDFV2 = async ({ const stage = new Konva.Stage({ width: pageWidth, height: pageHeight }); const layer = new Konva.Layer(); - const insertedFields = fields.filter((field) => field.inserted); - // Render the fields onto the layer. - for (const field of insertedFields) { + for (const field of fields) { renderField({ scale: 1, field: { diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index 590b77d0e..b406f5d00 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -1,9 +1,46 @@ +import { msg } from '@lingui/core/macro'; 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 FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN = 'middle'; +export const FIELD_DEFAULT_GENERIC_ALIGN = 'left'; +export const FIELD_DEFAULT_LINE_HEIGHT = 1; +export const FIELD_DEFAULT_LETTER_SPACING = 0; + +export const FIELD_MIN_LINE_HEIGHT = 1; +export const FIELD_MAX_LINE_HEIGHT = 10; + +export const FIELD_MIN_LETTER_SPACING = 0; +export const FIELD_MAX_LETTER_SPACING = 100; + +export const DEFAULT_FIELD_FONT_SIZE = 12; + +/** + * Grouped field types that use the same generic text rendering function. + */ +export type GenericTextFieldTypeMetas = + | TInitialsFieldMeta + | TNameFieldMeta + | TEmailFieldMeta + | TDateFieldMeta + | TTextFieldMeta + | TNumberFieldMeta; + +const ZFieldMetaLineHeight = z.coerce + .number() + .min(FIELD_MIN_LINE_HEIGHT) + .max(FIELD_MAX_LINE_HEIGHT) + .describe('The line height of the text'); +const ZFieldMetaLetterSpacing = z.coerce + .number() + .min(FIELD_MIN_LETTER_SPACING) + .max(FIELD_MAX_LETTER_SPACING) + .describe('The spacing between each character'); +const ZFieldMetaVerticalAlign = z + .enum(['top', 'middle', 'bottom']) + .describe('The vertical alignment of the text'); export const ZBaseFieldMeta = z.object({ label: z.string().optional(), @@ -50,8 +87,14 @@ export type TDateFieldMeta = z.infer; export const ZTextFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('text'), text: z.string().optional(), - characterLimit: z.number().optional(), + characterLimit: z.coerce + .number({ invalid_type_error: msg`Value must be a number`.id }) + .min(0) + .optional(), textAlign: ZFieldTextAlignSchema.optional(), + lineHeight: ZFieldMetaLineHeight.nullish(), + letterSpacing: ZFieldMetaLetterSpacing.nullish(), + verticalAlign: ZFieldMetaVerticalAlign.nullish(), }); export type TTextFieldMeta = z.infer; @@ -63,6 +106,9 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({ minValue: z.coerce.number().nullish(), maxValue: z.coerce.number().nullish(), textAlign: ZFieldTextAlignSchema.optional(), + lineHeight: ZFieldMetaLineHeight.nullish(), + letterSpacing: ZFieldMetaLetterSpacing.nullish(), + verticalAlign: ZFieldMetaVerticalAlign.nullish(), }); export type TNumberFieldMeta = z.infer; diff --git a/packages/lib/universal/field-renderer/render-checkbox-field.ts b/packages/lib/universal/field-renderer/render-checkbox-field.ts index 8a927201d..00e499c63 100644 --- a/packages/lib/universal/field-renderer/render-checkbox-field.ts +++ b/packages/lib/universal/field-renderer/render-checkbox-field.ts @@ -26,7 +26,7 @@ export const renderCheckboxFieldElement = ( field: FieldToRender, options: RenderFieldElementOptions, ) => { - const { pageWidth, pageHeight, pageLayer, mode } = options; + const { pageWidth, pageHeight, pageLayer, mode, color } = options; const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); @@ -210,7 +210,9 @@ export const renderCheckboxFieldElement = ( fieldGroup.add(text); }); - createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + if (color !== 'readOnly' && mode !== 'export') { + createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + } return { fieldGroup, diff --git a/packages/lib/universal/field-renderer/render-dropdown-field.ts b/packages/lib/universal/field-renderer/render-dropdown-field.ts index 15dd03e14..4d8e826f7 100644 --- a/packages/lib/universal/field-renderer/render-dropdown-field.ts +++ b/packages/lib/universal/field-renderer/render-dropdown-field.ts @@ -50,7 +50,7 @@ export const renderDropdownFieldElement = ( field: FieldToRender, options: RenderFieldElementOptions, ) => { - const { pageWidth, pageHeight, pageLayer, mode, translations } = options; + const { pageWidth, pageHeight, pageLayer, mode, translations, color } = options; const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); @@ -74,6 +74,21 @@ export const renderDropdownFieldElement = ( const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE; + // Don't show any labels when exporting. + if (mode === 'export') { + selectedValue = ''; + } + + // Render the default value if readonly. + if ( + dropdownMeta?.readOnly && + dropdownMeta.defaultValue && + dropdownMeta.values && + dropdownMeta.values.some((value) => value.value === dropdownMeta.defaultValue) + ) { + selectedValue = dropdownMeta.defaultValue; + } + if (field.inserted) { selectedValue = field.customText; } @@ -166,7 +181,9 @@ export const renderDropdownFieldElement = ( pageLayer.batchDraw(); }); - createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + if (color !== 'readOnly' && mode !== 'export') { + createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + } return { fieldGroup, diff --git a/packages/lib/universal/field-renderer/render-field.ts b/packages/lib/universal/field-renderer/render-field.ts index 2d657f06c..d7000389a 100644 --- a/packages/lib/universal/field-renderer/render-field.ts +++ b/packages/lib/universal/field-renderer/render-field.ts @@ -77,6 +77,7 @@ export const renderField = ({ scale, }; + // If the generic text field element array changes, update the `GenericTextFieldTypeMetas` type return match(field.type) .with( FieldType.INITIALS, diff --git a/packages/lib/universal/field-renderer/render-generic-text-field.ts b/packages/lib/universal/field-renderer/render-generic-text-field.ts index 20fd87d6a..525d162c9 100644 --- a/packages/lib/universal/field-renderer/render-generic-text-field.ts +++ b/packages/lib/universal/field-renderer/render-generic-text-field.ts @@ -1,7 +1,13 @@ import Konva from 'konva'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; -import type { TTextFieldMeta } from '../../types/field-meta'; +import type { GenericTextFieldTypeMetas } from '../../types/field-meta'; +import { + FIELD_DEFAULT_GENERIC_ALIGN, + FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN, + FIELD_DEFAULT_LETTER_SPACING, + FIELD_DEFAULT_LINE_HEIGHT, +} from '../../types/field-meta'; import { createFieldHoverInteraction, konvaTextFill, @@ -12,14 +18,14 @@ import { import type { FieldToRender, RenderFieldElementOptions } from './field-renderer'; import { calculateFieldPosition } from './field-renderer'; -const DEFAULT_TEXT_ALIGN = 'left'; +const DEFAULT_TEXT_X_PADDING = 6; const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => { const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options; const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); - const textMeta = field.fieldMeta as TTextFieldMeta | undefined; + const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined; const fieldTypeName = translations?.[field.type] || field.type; @@ -33,53 +39,62 @@ 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 || DEFAULT_TEXT_ALIGN; - const textVerticalAlign: 'top' | 'middle' | 'bottom' = 'middle'; - const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE; - const textPadding = 10; + const textFontSize = fieldMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE; - let textToRender: string = fieldTypeName; + // By default, render the field name or label centered + let textToRender: string = fieldMeta?.label || fieldTypeName; + let textAlign: 'left' | 'center' | 'right' = 'center'; + let textVerticalAlign: 'top' | 'middle' | 'bottom' = FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN; + let textLineHeight = FIELD_DEFAULT_LINE_HEIGHT; + let textLetterSpacing = FIELD_DEFAULT_LETTER_SPACING; - // Handle edit mode. - if (mode === 'edit') { - if (textMeta?.text) { - textToRender = textMeta.text; - } else { - // Show field name which is centered for the edit mode if no label/text is avaliable. - textToRender = textMeta?.label || fieldTypeName; - textAlign = 'center'; + // Default to blank for export mode since this we want to ensure we don't show + // any placeholder text or labels unless actually it's inserted. + if (mode === 'export') { + textToRender = ''; + } + + // Use default values for text/number if provided. + if (fieldMeta?.type === 'text' || fieldMeta?.type === 'number') { + const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value; + + if (value) { + textToRender = value; + + textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN; + textAlign = fieldMeta.textAlign || FIELD_DEFAULT_GENERIC_ALIGN; + textLetterSpacing = fieldMeta.letterSpacing || FIELD_DEFAULT_LETTER_SPACING; + textLineHeight = fieldMeta.lineHeight || FIELD_DEFAULT_LINE_HEIGHT; } } - // Handle sign mode. - if (mode === 'sign' || mode === 'export') { - if (!field.inserted) { - if (textMeta?.text) { - textToRender = textMeta.text; - } else if (mode === 'sign') { - // Only show the field name in sign mode if no text/label is avaliable. - textToRender = textMeta?.label || fieldTypeName; - textAlign = 'center'; - } - } + if (field.inserted) { + textToRender = field.customText; - if (field.inserted) { - textToRender = field.customText; + textAlign = fieldMeta?.textAlign || FIELD_DEFAULT_GENERIC_ALIGN; + + if (fieldMeta?.type === 'text' || fieldMeta?.type === 'number') { + textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN; + textLetterSpacing = fieldMeta.letterSpacing || FIELD_DEFAULT_LETTER_SPACING; + textLineHeight = fieldMeta.lineHeight || FIELD_DEFAULT_LINE_HEIGHT; } } + // Note: Do not use native text padding since it's uniform. + // We only want to have padding on the left and right hand sides. fieldText.setAttrs({ - x: textX, + x: textX + DEFAULT_TEXT_X_PADDING, y: textY, verticalAlign: textVerticalAlign, wrap: 'word', - padding: textPadding, text: textToRender, fontSize: textFontSize, + align: textAlign, + lineHeight: textLineHeight, + letterSpacing: textLetterSpacing, fontFamily: konvaTextFontFamily, fill: konvaTextFill, - align: textAlign, - width: fieldWidth, + width: fieldWidth - DEFAULT_TEXT_X_PADDING * 2, height: fieldHeight, } satisfies Partial); @@ -90,7 +105,7 @@ export const renderGenericTextFieldElement = ( field: FieldToRender, options: RenderFieldElementOptions, ) => { - const { mode = 'edit', pageLayer } = options; + const { mode = 'edit', pageLayer, color } = options; const isFirstRender = !pageLayer.findOne(`#${field.renderId}`); @@ -125,7 +140,7 @@ export const renderGenericTextFieldElement = ( const rectHeight = fieldRect.height() * groupScaleY; // Update text dimensions - fieldText.width(rectWidth); + fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2); fieldText.height(rectHeight); // Force Konva to recalculate text layout @@ -143,7 +158,7 @@ export const renderGenericTextFieldElement = ( const rectHeight = fieldRect.height(); // Update text dimensions - fieldText.width(rectWidth); // Account for padding + fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2); fieldText.height(rectHeight); // Force Konva to recalculate text layout @@ -158,7 +173,9 @@ export const renderGenericTextFieldElement = ( fieldRect.opacity(0); } - createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + if (color !== 'readOnly' && mode !== 'export') { + createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + } return { fieldGroup, diff --git a/packages/lib/universal/field-renderer/render-radio-field.ts b/packages/lib/universal/field-renderer/render-radio-field.ts index 5d173b0e3..ebeaa70cb 100644 --- a/packages/lib/universal/field-renderer/render-radio-field.ts +++ b/packages/lib/universal/field-renderer/render-radio-field.ts @@ -25,7 +25,7 @@ export const renderRadioFieldElement = ( field: FieldToRender, options: RenderFieldElementOptions, ) => { - const { pageWidth, pageHeight, pageLayer, mode } = options; + const { pageWidth, pageHeight, pageLayer, mode, color } = options; const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null; const radioValues = radioMeta?.values || []; @@ -195,7 +195,9 @@ export const renderRadioFieldElement = ( fieldGroup.add(text); }); - createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + if (color !== 'readOnly' && mode !== 'export') { + createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + } return { fieldGroup, diff --git a/packages/lib/universal/field-renderer/render-signature-field.ts b/packages/lib/universal/field-renderer/render-signature-field.ts index 2b8877484..e57074f8f 100644 --- a/packages/lib/universal/field-renderer/render-signature-field.ts +++ b/packages/lib/universal/field-renderer/render-signature-field.ts @@ -142,7 +142,7 @@ export const renderSignatureFieldElement = ( field: FieldToRender, options: RenderFieldElementOptions, ) => { - const { mode = 'edit', pageLayer } = options; + const { mode = 'edit', pageLayer, color } = options; const isFirstRender = !pageLayer.findOne(`#${field.renderId}`); @@ -211,7 +211,9 @@ export const renderSignatureFieldElement = ( fieldRect.opacity(0); } - createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + if (color !== 'readOnly' && mode !== 'export') { + createFieldHoverInteraction({ fieldGroup, fieldRect, options }); + } return { fieldGroup, diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index 352b9d23f..102af901a 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { formatAlignmentTestFields } from '@documenso/app-tests/constants/field-alignment-pdf'; +import { ALIGNMENT_TEST_FIELDS } from '@documenso/app-tests/constants/field-alignment-pdf'; import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf'; import { isBase64Image } from '@documenso/lib/constants/signatures'; import { incrementDocumentId } from '@documenso/lib/server-only/envelope/increment-id'; @@ -301,7 +301,7 @@ export const seedAlignmentTestDocument = async ({ } await Promise.all( - formatAlignmentTestFields.map(async (field) => { + ALIGNMENT_TEST_FIELDS.map(async (field) => { await prisma.field.create({ data: { ...field, @@ -309,7 +309,10 @@ export const seedAlignmentTestDocument = async ({ envelopeItemId: envelopeItemAlignmentItem, envelopeId: id, customText: insertFields ? field.customText : '', - inserted: insertFields, + inserted: + insertFields && + ((!field?.fieldMeta?.readOnly && Boolean(field.customText)) || + field.type === 'SIGNATURE'), signature: field.signature ? { create: { @@ -333,7 +336,10 @@ export const seedAlignmentTestDocument = async ({ envelopeItemId: envelopeItemFieldMetaItem, envelopeId: id, customText: insertFields ? field.customText : '', - inserted: insertFields, + inserted: + insertFields && + ((!field?.fieldMeta?.readOnly && Boolean(field.customText)) || + field.type === 'SIGNATURE'), signature: field.signature ? { create: { diff --git a/packages/prisma/seed/organisations.ts b/packages/prisma/seed/organisations.ts index c2c3a6c7c..0e69b13f2 100644 --- a/packages/prisma/seed/organisations.ts +++ b/packages/prisma/seed/organisations.ts @@ -64,6 +64,7 @@ export const seedOrganisationMembers = async ({ organisationId, organisationGroups, organisationMemberRole: member.organisationRole, + bypassEmail: true, }); } diff --git a/packages/ui/components/document/envelope-recipient-field-tooltip.tsx b/packages/ui/components/document/envelope-recipient-field-tooltip.tsx index 323f83e03..194c3c8f6 100644 --- a/packages/ui/components/document/envelope-recipient-field-tooltip.tsx +++ b/packages/ui/components/document/envelope-recipient-field-tooltip.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Trans, useLingui } from '@lingui/react/macro'; import { SigningStatus } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client'; -import { ClockIcon, EyeOffIcon } from 'lucide-react'; +import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; @@ -20,7 +20,15 @@ import { PopoverHover } from '../../primitives/popover'; interface EnvelopeRecipientFieldTooltipProps { field: Pick< Field, - 'id' | 'inserted' | 'positionX' | 'positionY' | 'width' | 'height' | 'page' | 'type' + | 'id' + | 'inserted' + | 'positionX' + | 'positionY' + | 'width' + | 'height' + | 'page' + | 'type' + | 'fieldMeta' > & { recipient: Pick; }; @@ -151,10 +159,19 @@ export function EnvelopeRecipientFieldTooltip({ - {field.recipient.signingStatus === SigningStatus.SIGNED ? ( + {field?.fieldMeta?.readOnly ? ( + <> + + Read Only + + ) : field.recipient.signingStatus === SigningStatus.SIGNED ? ( <> Signed