mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
feat: add ability to save documents as template (#2661)
This commit is contained in:
parent
74d79dc6b2
commit
e3b7a9e7cb
12 changed files with 851 additions and 43 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export default function EmbedPlaygroundPage() {
|
|||
allowDistributing: false,
|
||||
allowDirectLink: false,
|
||||
allowDuplication: false,
|
||||
allowSaveAsTemplate: false,
|
||||
allowDownloadPDF: false,
|
||||
allowDeletion: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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_/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
39
packages/trpc/server/envelope-router/save-as-template.ts
Normal file
39
packages/trpc/server/envelope-router/save-as-template.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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>;
|
||||
Loading…
Reference in a new issue