mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
feat: allow empty placeholder emails on templates (#1930)
Allow users to create template placeholders without the placeholder emails.
This commit is contained in:
@@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
|||||||
import {
|
import {
|
||||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||||
|
isTemplateRecipientEmailPlaceholder,
|
||||||
} from '@documenso/lib/constants/template';
|
} from '@documenso/lib/constants/template';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
@@ -279,7 +280,11 @@ export function TemplateUseDialog({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
placeholder={recipients[index].email || _(msg`Email`)}
|
placeholder={
|
||||||
|
isTemplateRecipientEmailPlaceholder(field.value)
|
||||||
|
? ''
|
||||||
|
: _(msg`Email`)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { PenIcon, PlusIcon } from 'lucide-react';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
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';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
export type TemplatePageViewRecipientsProps = {
|
export type TemplatePageViewRecipientsProps = {
|
||||||
@@ -53,8 +55,18 @@ export const TemplatePageViewRecipients = ({
|
|||||||
{recipients.map((recipient) => (
|
{recipients.map((recipient) => (
|
||||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
avatarFallback={
|
||||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
isTemplateRecipientEmailPlaceholder(recipient.email)
|
||||||
|
? extractInitials(recipient.name)
|
||||||
|
: recipient.email.slice(0, 1).toUpperCase()
|
||||||
|
}
|
||||||
|
primaryText={
|
||||||
|
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{recipient.name}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
secondaryText={
|
secondaryText={
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||||
|
|||||||
@@ -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_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
|
||||||
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/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 = [
|
export const DIRECT_TEMPLATE_DOCUMENTATION = [
|
||||||
{
|
{
|
||||||
title: msg`Enable Direct Link Signing`,
|
title: msg`Enable Direct Link Signing`,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||||
import {
|
import {
|
||||||
ZRecipientAccessAuthTypesSchema,
|
ZRecipientAccessAuthTypesSchema,
|
||||||
ZRecipientActionAuthSchema,
|
ZRecipientActionAuthSchema,
|
||||||
@@ -186,7 +187,18 @@ export const ZSetTemplateRecipientsRequestSchema = z
|
|||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
nativeId: z.number().optional(),
|
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(),
|
name: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
@@ -196,9 +208,12 @@ export const ZSetTemplateRecipientsRequestSchema = z
|
|||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(schema) => {
|
(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
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SigningStatus } from '@prisma/client';
|
|||||||
import { Clock, EyeOffIcon } from 'lucide-react';
|
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
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 type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
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 { getRecipientColorStyles } from '../../lib/recipient-colors';
|
||||||
import { FieldContent } from '../../primitives/document-flow/field-content';
|
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 = {
|
export type DocumentReadOnlyFieldsProps = {
|
||||||
fields: DocumentField[];
|
fields: DocumentField[];
|
||||||
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
|
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
|
||||||
@@ -145,9 +158,7 @@ export const DocumentReadOnlyFields = ({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||||
{field.recipient.name
|
{getRecipientDisplayText(field.recipient)}
|
||||||
? `${field.recipient.name} (${field.recipient.email})`
|
|
||||||
: field.recipient.email}{' '}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
|||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||||
import {
|
import {
|
||||||
type TFieldMetaSchema as FieldMeta,
|
type TFieldMetaSchema as FieldMeta,
|
||||||
ZFieldMetaSchema,
|
ZFieldMetaSchema,
|
||||||
@@ -593,15 +594,21 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
selectedSignerStyles?.comboxBoxTrigger,
|
selectedSignerStyles?.comboxBoxTrigger,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedSigner?.email && (
|
{selectedSigner?.email &&
|
||||||
<span className="flex-1 truncate text-left">
|
!isTemplateRecipientEmailPlaceholder(selectedSigner.email) && (
|
||||||
{selectedSigner?.name} ({selectedSigner?.email})
|
<span className="flex-1 truncate text-left">
|
||||||
</span>
|
{selectedSigner?.name} ({selectedSigner?.email})
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSigner?.email &&
|
||||||
|
isTemplateRecipientEmailPlaceholder(selectedSigner.email) && (
|
||||||
|
<span className="flex-1 truncate text-left">{selectedSigner?.name}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{!selectedSigner?.email && (
|
{!selectedSigner?.email && (
|
||||||
<span className="gradie flex-1 truncate text-left">
|
<span className="gradie flex-1 truncate text-left">
|
||||||
{selectedSigner?.email}
|
No recipient selected
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -657,15 +664,22 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
'text-foreground/80': recipient === selectedSigner,
|
'text-foreground/80': recipient === selectedSigner,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{recipient.name && (
|
{recipient.name &&
|
||||||
<span title={`${recipient.name} (${recipient.email})`}>
|
!isTemplateRecipientEmailPlaceholder(recipient.email) && (
|
||||||
{recipient.name} ({recipient.email})
|
<span title={`${recipient.name} (${recipient.email})`}>
|
||||||
</span>
|
{recipient.name} ({recipient.email})
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{!recipient.name && (
|
{recipient.name &&
|
||||||
<span title={recipient.email}>{recipient.email}</span>
|
isTemplateRecipientEmailPlaceholder(recipient.email) && (
|
||||||
)}
|
<span title={recipient.name}>{recipient.name}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!recipient.name &&
|
||||||
|
!isTemplateRecipientEmailPlaceholder(recipient.email) && (
|
||||||
|
<span title={recipient.email}>{recipient.email}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
|||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
|
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
|
||||||
@@ -247,62 +248,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
[form, watchedSigners, toast],
|
[form, watchedSigners, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const triggerDragAndDrop = useCallback(
|
|
||||||
(fromIndex: number, toIndex: number) => {
|
|
||||||
if (!$sensorApi.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const draggableId = signers[fromIndex].id;
|
|
||||||
|
|
||||||
const preDrag = $sensorApi.current.tryGetLock(draggableId);
|
|
||||||
|
|
||||||
if (!preDrag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const drag = preDrag.snapLift();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
// Move directly to the target index
|
|
||||||
if (fromIndex < toIndex) {
|
|
||||||
for (let i = fromIndex; i < toIndex; i++) {
|
|
||||||
drag.moveDown();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = fromIndex; i > toIndex; i--) {
|
|
||||||
drag.moveUp();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
drag.drop();
|
|
||||||
}, 500);
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[signers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSigningOrders = useCallback(
|
|
||||||
(newIndex: number, oldIndex: number) => {
|
|
||||||
const updatedSigners = form.getValues('signers').map((signer, index) => {
|
|
||||||
if (index === oldIndex) {
|
|
||||||
return { ...signer, signingOrder: newIndex + 1 };
|
|
||||||
} else if (index >= newIndex && index < oldIndex) {
|
|
||||||
return { ...signer, signingOrder: (signer.signingOrder ?? index + 1) + 1 };
|
|
||||||
} else if (index <= newIndex && index > oldIndex) {
|
|
||||||
return { ...signer, signingOrder: Math.max(1, (signer.signingOrder ?? index + 1) - 1) };
|
|
||||||
}
|
|
||||||
return signer;
|
|
||||||
});
|
|
||||||
|
|
||||||
updatedSigners.forEach((signer, index) => {
|
|
||||||
form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[form],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSigningOrderChange = useCallback(
|
const handleSigningOrderChange = useCallback(
|
||||||
(index: number, newOrderString: string) => {
|
(index: number, newOrderString: string) => {
|
||||||
const trimmedOrderString = newOrderString.trim();
|
const trimmedOrderString = newOrderString.trim();
|
||||||
@@ -592,7 +537,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{!showAdvancedSettings && index === 0 && (
|
{!showAdvancedSettings && index === 0 && (
|
||||||
<FormLabel required>
|
<FormLabel>
|
||||||
<Trans>Email</Trans>
|
<Trans>Email</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
@@ -602,6 +547,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
type="email"
|
type="email"
|
||||||
placeholder={_(msg`Email`)}
|
placeholder={_(msg`Email`)}
|
||||||
{...field}
|
{...field}
|
||||||
|
value={
|
||||||
|
isTemplateRecipientEmailPlaceholder(field.value)
|
||||||
|
? ''
|
||||||
|
: field.value
|
||||||
|
}
|
||||||
disabled={
|
disabled={
|
||||||
field.disabled ||
|
field.disabled ||
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
|
||||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||||
|
|
||||||
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
||||||
@@ -10,7 +11,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
|||||||
formId: z.string().min(1),
|
formId: z.string().min(1),
|
||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
email: z.string().min(1).email(),
|
email: z.string().min(1).email(),
|
||||||
name: z.string(),
|
name: z.string().min(1, { message: 'Name is required' }),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||||
@@ -21,12 +22,25 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
|||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(schema) => {
|
(schema) => {
|
||||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
const nonPlaceholderEmails = schema.signers
|
||||||
|
.map((signer) => signer.email.toLowerCase())
|
||||||
|
.filter((email) => !TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(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
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
/*
|
||||||
|
Since placeholder emails are empty, we need to check that the names are unique.
|
||||||
|
If we don't do this, the app will add duplicate signers and merge them in the next step, where you add fields.
|
||||||
|
*/
|
||||||
|
(schema) => {
|
||||||
|
const names = schema.signers.map((signer) => signer.name.trim());
|
||||||
|
return new Set(names).size === names.length;
|
||||||
|
},
|
||||||
|
{ message: 'Signers must have unique names', path: ['signers__root'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
|
export type TAddTemplatePlacholderRecipientsFormSchema = z.infer<
|
||||||
|
|||||||
Reference in New Issue
Block a user