feat: add ability to save documents as template (#2661)

This commit is contained in:
David Nguyen 2026-04-01 16:03:26 +11:00 committed by GitHub
parent 74d79dc6b2
commit e3b7a9e7cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 851 additions and 43 deletions

View file

@ -0,0 +1,174 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type EnvelopeSaveAsTemplateDialogProps = {
envelopeId: string;
trigger?: React.ReactNode;
};
export const EnvelopeSaveAsTemplateDialog = ({
envelopeId,
trigger,
}: EnvelopeSaveAsTemplateDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { toast } = useToast();
const { t } = useLingui();
const team = useCurrentTeam();
const templatesPath = formatTemplatesPath(team.url);
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: t`Template Created`,
description: t`Your document has been saved as a template.`,
duration: 5000,
});
await navigate(`${templatesPath}/${id}/edit`);
setOpen(false);
},
});
const onSubmit = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await saveAsTemplate({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
} catch {
toast({
title: t`Something went wrong`,
description: t`This document could not be saved as a template at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (isPending) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Save as Template</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create a template from this document.</Trans>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label
htmlFor="envelopeIncludeFields"
className={!includeRecipients ? 'opacity-50' : ''}
>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" loading={isPending} onClick={onSubmit}>
<Trans>Save as Template</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -1,12 +1,12 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import {
Copy,
Download,
Edit,
FileOutputIcon,
Loader,
MoreHorizontal,
ScrollTextIcon,
@ -28,12 +28,12 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team';
@ -43,8 +43,6 @@ export type DocumentPageViewDropdownProps = {
export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const team = useCurrentTeam();
@ -68,8 +66,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
@ -113,6 +111,18 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<FileOutputIcon className="mr-2 h-4 w-4" />
<Trans>Save as Template</Trans>
</div>
</DropdownMenuItem>
}
/>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={isDeleted}>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>

View file

@ -10,6 +10,7 @@ import {
CopyPlusIcon,
DownloadCloudIcon,
EyeIcon,
FileOutputIcon,
LinkIcon,
type LucideIcon,
MousePointerIcon,
@ -35,6 +36,7 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
@ -101,6 +103,7 @@ export const EnvelopeEditor = () => {
allowDistributing,
allowDirectLink,
allowDuplication,
allowSaveAsTemplate,
allowDownloadPDF,
allowDeletion,
},
@ -466,6 +469,28 @@ export const EnvelopeEditor = () => {
/>
)}
{allowSaveAsTemplate && isDocument && (
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Save as Template`)}
>
<FileOutputIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Save as Template</Trans>
</span>
)}
</Button>
}
/>
)}
{allowDownloadPDF && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}

View file

@ -10,6 +10,7 @@ import {
Download,
Edit,
EyeIcon,
FileOutputIcon,
FolderInput,
Loader,
MoreHorizontal,
@ -35,6 +36,7 @@ import {
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team';
@ -76,7 +78,7 @@ export const DocumentsTableActionDropdown = ({
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="document-table-action-btn">
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
@ -140,6 +142,18 @@ export const DocumentsTableActionDropdown = ({
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<EnvelopeSaveAsTemplateDialog
envelopeId={row.envelopeId}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<FileOutputIcon className="mr-2 h-4 w-4" />
<Trans>Save as Template</Trans>
</div>
</DropdownMenuItem>
}
/>
{onMoveDocument && canManageDocument && (
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
<FolderInput className="mr-2 h-4 w-4" />

View file

@ -70,6 +70,7 @@ export default function EmbedPlaygroundPage() {
allowDistributing: false,
allowDirectLink: false,
allowDuplication: false,
allowSaveAsTemplate: false,
allowDownloadPDF: false,
allowDeletion: false,
});

View file

@ -0,0 +1,495 @@
import { type Page, expect, test } from '@playwright/test';
import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { apiSignin } from '../fixtures/authentication';
import {
clickAddMyselfButton,
clickEnvelopeEditorStep,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Create a V2 document envelope via the API with a single SIGNER recipient
* and a SIGNATURE field, suitable for testing save-as-template with data.
*
* Returns the envelope ID, user, team, and recipient email.
*/
const createDocumentWithRecipientAndField = async () => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `e2e-save-as-template-${Date.now()}`,
expiresIn: null,
});
const recipientEmail = `save-template-${Date.now()}@test.documenso.com`;
// 1. Create envelope with a PDF.
const payload = {
type: EnvelopeType.DOCUMENT,
title: `E2E Save as Template ${Date.now()}`,
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append(
'files',
new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }),
);
const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
expect(createRes.ok).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// 2. Create a SIGNER recipient.
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: recipientEmail,
name: 'Template Test Signer',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const recipientsRes = await fetch(`${V2_API_BASE_URL}/envelope/recipient/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createRecipientsRequest),
});
expect(recipientsRes.ok).toBeTruthy();
const recipientsResponse = await recipientsRes.json();
const recipients = recipientsResponse.data;
// 3. Get envelope to find the envelope item ID.
const getRes = await fetch(`${V2_API_BASE_URL}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
const envelope = (await getRes.json()) as TGetEnvelopeResponse;
const envelopeItem = envelope.envelopeItems[0];
// 4. Create a SIGNATURE field for the recipient.
const createFieldsRequest = {
envelopeId: createResponse.id,
data: [
{
recipientId: recipients[0].id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const fieldsRes = await fetch(`${V2_API_BASE_URL}/envelope/field/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(createFieldsRequest),
});
expect(fieldsRes.ok).toBeTruthy();
return {
user,
team,
envelopeId: createResponse.id,
recipientEmail,
};
};
test.describe('document editor', () => {
test('save document as template from editor sidebar', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
// Add the current user as a recipient via the UI.
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
// Navigate to the add fields step and place a signature field.
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
// Navigate back to the upload step so the sidebar actions are available.
await clickEnvelopeEditorStep(surface.root, 'upload');
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The save as template dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Both checkboxes should be checked by default.
await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).toBeChecked();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the new template envelope was created in the database with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
expect(templateEnvelopes.length).toBeGreaterThanOrEqual(1);
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix, not document_.
// This confirms incrementTemplateId was called, not incrementDocumentId.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
// Verify recipients were included.
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].email).toBe(surface.userEmail);
// Verify fields were included.
expect(createdTemplate!.recipients[0].fields.length).toBe(1);
expect(createdTemplate!.recipients[0].fields[0].type).toBe(FieldType.SIGNATURE);
});
test('save document as template without recipients', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Uncheck "Include Recipients" - "Include Fields" should auto-disable.
await page.locator('#envelopeIncludeRecipients').click();
await expect(page.locator('#envelopeIncludeRecipients')).not.toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).toBeDisabled();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created without recipients.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: true,
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
// Verify no recipients were included.
expect(createdTemplate!.recipients.length).toBe(0);
});
test('save document as template without fields but with recipients', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Uncheck "Include Fields" but keep "Include Recipients".
await page.locator('#envelopeIncludeFields').click();
await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked();
await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with recipients but no fields.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
// Verify recipients were included.
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].email).toBe(recipientEmail);
// Verify no fields were included.
expect(createdTemplate!.recipients[0].fields.length).toBe(0);
});
});
test.describe('documents table', () => {
test('save as template from document row dropdown', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Wait for the documents table to load.
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the actions dropdown for the document row.
await page.getByTestId('document-table-action-btn').first().click();
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
// Verify recipients and fields were included (defaults are both checked).
expect(createdTemplate!.recipients.length).toBe(1);
expect(createdTemplate!.recipients[0].fields.length).toBe(1);
});
});
test.describe('document index page', () => {
test('save as template from document page dropdown', async ({ page }) => {
const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelopeId}`,
});
// Wait for the document page to load.
await expect(page.getByRole('heading', { name: /E2E Save as Template/ })).toBeVisible();
// Click the more actions dropdown trigger.
await page.getByTestId('document-page-view-action-btn').click();
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
// The dialog should appear.
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
// Assert toast appears.
await expectToastTextToBeVisible(page, 'Template Created');
// The page should have navigated to the new template's edit page.
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Verify the template was created with correct type and secondaryId.
const templateEnvelopes = await prisma.envelope.findMany({
where: {
userId: user.id,
teamId: team.id,
type: 'TEMPLATE',
},
});
const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)'));
expect(createdTemplate).toBeDefined();
// CRITICAL: Verify the secondaryId uses the template_ prefix.
expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/);
expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE);
});
});
test.describe('legacy ID correctness', () => {
test('save as template uses template counter, not document counter', async ({ page }) => {
// Record the current counter values before the operation.
const [documentCounterBefore, templateCounterBefore] = await Promise.all([
prisma.counter.findUnique({ where: { id: 'document' } }),
prisma.counter.findUnique({ where: { id: 'template' } }),
]);
const surface = await openDocumentEnvelopeEditor(page);
// Click the "Save as Template" sidebar action.
await page.locator('button[title="Save as Template"]').click();
await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible();
// Click "Save as Template".
await page.getByRole('button', { name: 'Save as Template' }).click();
await expectToastTextToBeVisible(page, 'Template Created');
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Record the counter values after the operation.
const [documentCounterAfter, templateCounterAfter] = await Promise.all([
prisma.counter.findUnique({ where: { id: 'document' } }),
prisma.counter.findUnique({ where: { id: 'template' } }),
]);
// The template counter MUST have incremented (at least once - could be more due to
// the seedBlankDocument call in openDocumentEnvelopeEditor seeding other templates).
expect(templateCounterAfter!.value).toBeGreaterThan(templateCounterBefore!.value);
// Verify the created template's secondaryId matches the template counter.
const createdTemplate = await prisma.envelope.findFirst({
where: {
userId: surface.userId,
teamId: surface.teamId,
type: 'TEMPLATE',
},
orderBy: { createdAt: 'desc' },
});
expect(createdTemplate).not.toBeNull();
expect(createdTemplate!.secondaryId).toBe(`template_${templateCounterAfter!.value}`);
expect(createdTemplate!.secondaryId).not.toMatch(/^document_/);
});
});

View file

@ -19,9 +19,25 @@ export interface DuplicateEnvelopeOptions {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
overrides?: {
duplicateAsTemplate?: boolean;
includeRecipients?: boolean;
includeFields?: boolean;
};
}
export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelopeOptions) => {
export const duplicateEnvelope = async ({
id,
userId,
teamId,
overrides,
}: DuplicateEnvelopeOptions) => {
const {
duplicateAsTemplate = false,
includeRecipients = true,
includeFields = true,
} = overrides ?? {};
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: null,
@ -72,8 +88,16 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
});
}
if (duplicateAsTemplate && envelope.type !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Only documents can be saved as templates',
});
}
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
envelope.type === EnvelopeType.DOCUMENT
targetType === EnvelopeType.DOCUMENT
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
legacyNumberId: documentId,
secondaryId: formattedDocumentId,
@ -99,7 +123,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
data: {
id: prefixedId('envelope'),
secondaryId,
type: envelope.type,
type: targetType,
internalVersion: envelope.internalVersion,
userId,
teamId,
@ -111,7 +135,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
publicTitle: envelope.publicTitle ?? undefined,
publicDescription: envelope.publicDescription ?? undefined,
source:
envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
targetType === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
},
include: {
recipients: true,
@ -148,38 +172,42 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
}),
);
await pMap(
envelope.recipients,
async (recipient) =>
prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
},
if (includeRecipients) {
await pMap(
envelope.recipients,
async (recipient) =>
prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: includeFields
? {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
},
}
: undefined,
},
},
}),
{ concurrency: 5 },
);
}),
{ concurrency: 5 },
);
}
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
const refetchedEnvelope = await prisma.envelope.findFirstOrThrow({
@ -204,7 +232,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
id: duplicatedEnvelope.id,
envelope: duplicatedEnvelope,
legacyId: {
type: envelope.type,
type: duplicatedEnvelope.type,
id: legacyNumberId,
},
};

View file

@ -56,6 +56,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
allowDistributing: z.boolean(),
allowDirectLink: z.boolean(),
allowDuplication: z.boolean(),
allowSaveAsTemplate: z.boolean(),
allowDownloadPDF: z.boolean(),
allowDeletion: z.boolean(),
}),
@ -129,6 +130,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
allowDistributing: true,
allowDirectLink: true,
allowDuplication: true,
allowSaveAsTemplate: true,
allowDownloadPDF: true,
allowDeletion: true,
},
@ -186,6 +188,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
allowDistributing: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowDirectLink: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowDuplication: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowSaveAsTemplate: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowDownloadPDF: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
allowDeletion: false, // These are not supported for embeds, and are directly excluded in the embedded repo.
},

View file

@ -88,6 +88,9 @@ export const buildEmbeddedFeatures = (
allowDuplication:
features.actions?.allowDuplication ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
allowSaveAsTemplate:
features.actions?.allowSaveAsTemplate ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowSaveAsTemplate,
allowDownloadPDF:
features.actions?.allowDownloadPDF ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,

View file

@ -29,6 +29,7 @@ import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
import { saveAsTemplateRoute } from './save-as-template';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
@ -91,6 +92,7 @@ export const envelopeRouter = router({
update: updateEnvelopeRoute,
delete: deleteEnvelopeRoute,
duplicate: duplicateEnvelopeRoute,
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
signingStatus: signingStatusEnvelopeRoute,

View file

@ -0,0 +1,39 @@
import { duplicateEnvelope } from '@documenso/lib/server-only/envelope/duplicate-envelope';
import { authenticatedProcedure } from '../trpc';
import {
ZSaveAsTemplateRequestSchema,
ZSaveAsTemplateResponseSchema,
} from './save-as-template.types';
export const saveAsTemplateRoute = authenticatedProcedure
.input(ZSaveAsTemplateRequestSchema)
.output(ZSaveAsTemplateResponseSchema)
.mutation(async ({ input, ctx }) => {
const { envelopeId, includeRecipients, includeFields } = input;
const { teamId } = ctx;
ctx.logger.info({
input: {
envelopeId,
},
});
const result = await duplicateEnvelope({
userId: ctx.user.id,
teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
overrides: {
duplicateAsTemplate: true,
includeRecipients,
includeFields,
},
});
return {
id: result.id,
};
});

View file

@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZSaveAsTemplateRequestSchema = z.object({
envelopeId: z.string().min(1).describe('The ID of the envelope to save as a template.'),
includeRecipients: z.boolean(),
includeFields: z.boolean(),
});
export const ZSaveAsTemplateResponseSchema = z.object({
id: z.string().describe('The ID of the newly created template envelope.'),
});
export type TSaveAsTemplateRequest = z.infer<typeof ZSaveAsTemplateRequestSchema>;
export type TSaveAsTemplateResponse = z.infer<typeof ZSaveAsTemplateResponseSchema>;