From deb3a63fb8ff6e5b7326cd5b2fd1a07a37c80df4 Mon Sep 17 00:00:00 2001
From: Catalin Pit
Date: Tue, 12 Aug 2025 13:41:23 +0300
Subject: [PATCH] feat: allow empty placeholder emails on templates (#1930)
Allow users to create template placeholders without the placeholder
emails.
---
.../dialogs/template-use-dialog.tsx | 7 +-
.../template-page-view-recipients.tsx | 16 ++++-
packages/lib/constants/template.ts | 4 ++
.../trpc/server/recipient-router/schema.ts | 21 +++++-
.../document/document-read-only-fields.tsx | 17 ++++-
.../template-flow/add-template-fields.tsx | 42 ++++++++----
.../add-template-placeholder-recipients.tsx | 64 ++-----------------
...d-template-placeholder-recipients.types.ts | 20 +++++-
8 files changed, 108 insertions(+), 83 deletions(-)
diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx
index 242d146b9..3d4e7ba61 100644
--- a/apps/remix/app/components/dialogs/template-use-dialog.tsx
+++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx
@@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
+ isTemplateRecipientEmailPlaceholder,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@@ -279,7 +280,11 @@ export function TemplateUseDialog({
diff --git a/apps/remix/app/components/general/template/template-page-view-recipients.tsx b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
index 0a65b3a09..3896baf11 100644
--- a/apps/remix/app/components/general/template/template-page-view-recipients.tsx
+++ b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
@@ -6,6 +6,8 @@ import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
@@ -53,8 +55,18 @@ export const TemplatePageViewRecipients = ({
{recipients.map((recipient) => (
{recipient.email}
}
+ avatarFallback={
+ isTemplateRecipientEmailPlaceholder(recipient.email)
+ ? extractInitials(recipient.name)
+ : recipient.email.slice(0, 1).toUpperCase()
+ }
+ primaryText={
+ isTemplateRecipientEmailPlaceholder(recipient.email) ? (
+ {recipient.name}
+ ) : (
+ {recipient.email}
+ )
+ }
secondaryText={
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
diff --git a/packages/lib/constants/template.ts b/packages/lib/constants/template.ts
index 7b6848a6b..05b9795c0 100644
--- a/packages/lib/constants/template.ts
+++ b/packages/lib/constants/template.ts
@@ -3,6 +3,10 @@ import { msg } from '@lingui/core/macro';
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
+export const isTemplateRecipientEmailPlaceholder = (email: string) => {
+ return TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email);
+};
+
export const DIRECT_TEMPLATE_DOCUMENTATION = [
{
title: msg`Enable Direct Link Signing`,
diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts
index 060b3a031..e7344a9da 100644
--- a/packages/trpc/server/recipient-router/schema.ts
+++ b/packages/trpc/server/recipient-router/schema.ts
@@ -1,6 +1,7 @@
import { RecipientRole } from '@prisma/client';
import { z } from 'zod';
+import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
ZRecipientAccessAuthTypesSchema,
ZRecipientActionAuthSchema,
@@ -186,7 +187,18 @@ export const ZSetTemplateRecipientsRequestSchema = z
recipients: z.array(
z.object({
nativeId: z.number().optional(),
- email: z.string().toLowerCase().email().min(1),
+ email: z
+ .string()
+ .toLowerCase()
+ .refine(
+ (email) => {
+ return (
+ isTemplateRecipientEmailPlaceholder(email) ||
+ z.string().email().safeParse(email).success
+ );
+ },
+ { message: 'Please enter a valid email address' },
+ ),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -196,9 +208,12 @@ export const ZSetTemplateRecipientsRequestSchema = z
})
.refine(
(schema) => {
- const emails = schema.recipients.map((recipient) => recipient.email);
+ // Filter out placeholder emails and only check uniqueness for actual emails
+ const nonPlaceholderEmails = schema.recipients
+ .map((recipient) => recipient.email)
+ .filter((email) => !isTemplateRecipientEmailPlaceholder(email));
- return new Set(emails).size === emails.length;
+ return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx
index 80e998c26..0786357d1 100644
--- a/packages/ui/components/document/document-read-only-fields.tsx
+++ b/packages/ui/components/document/document-read-only-fields.tsx
@@ -7,6 +7,7 @@ import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
@@ -21,6 +22,18 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
import { getRecipientColorStyles } from '../../lib/recipient-colors';
import { FieldContent } from '../../primitives/document-flow/field-content';
+const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
+ if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
+ return `${recipient.name} (${recipient.email})`;
+ }
+
+ if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
+ return recipient.name;
+ }
+
+ return recipient.email;
+};
+
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: Pick;
@@ -145,9 +158,7 @@ export const DocumentReadOnlyFields = ({
- {field.recipient.name
- ? `${field.recipient.name} (${field.recipient.email})`
- : field.recipient.email}{' '}
+ {getRecipientDisplayText(field.recipient)}