feat: implement auto-save functionality for signers in document edit form (#1792)

This commit is contained in:
Catalin Pit
2025-09-02 14:01:16 +03:00
committed by GitHub
parent 19565c1821
commit bb5c2edefd
22 changed files with 2482 additions and 220 deletions
@@ -159,34 +159,37 @@ export const DocumentEditForm = ({
return initialStep; return initialStep;
}); });
const saveSettingsData = async (data: TAddSettingsFormSchema) => {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
return updateDocument({
documentId: document.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
};
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try { try {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta; await saveSettingsData(data);
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
await updateDocument({
documentId: document.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
setStep('signers'); setStep('signers');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -199,26 +202,58 @@ export const DocumentEditForm = ({
} }
}; };
const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => {
try {
await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the document settings.`),
variant: 'destructive',
});
}
};
const saveSignersData = async (data: TAddSignersFormSchema) => {
return Promise.all([
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
},
}),
setRecipients({
documentId: document.id,
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth ?? [],
})),
}),
]);
};
const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => {
try {
await saveSignersData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try { try {
await Promise.all([ await saveSignersData(data);
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
},
}),
setRecipients({
documentId: document.id,
recipients: data.signers.map((signer) => ({
...signer,
// Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth ?? [],
})),
}),
]);
setStep('fields'); setStep('fields');
} catch (err) { } catch (err) {
@@ -232,12 +267,16 @@ export const DocumentEditForm = ({
} }
}; };
const saveFieldsData = async (data: TAddFieldsFormSchema) => {
return addFields({
documentId: document.id,
fields: data.fields,
});
};
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try { try {
await addFields({ await saveFieldsData(data);
documentId: document.id,
fields: data.fields,
});
// Clear all field data from localStorage // Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
@@ -259,24 +298,60 @@ export const DocumentEditForm = ({
} }
}; };
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const onAddFieldsFormAutoSave = async (data: TAddFieldsFormSchema) => {
try {
await saveFieldsData(data);
// Don't clear localStorage on auto-save, only on explicit submit
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the fields.`),
variant: 'destructive',
});
}
};
const saveSubjectData = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } = const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta; data.meta;
try { return updateDocument({
await sendDocument({ documentId: document.id,
documentId: document.id, meta: {
meta: { subject,
subject, message,
message, distributionMethod,
distributionMethod, emailId,
emailId, emailReplyTo,
emailReplyTo: emailReplyTo || null, emailSettings: emailSettings,
emailSettings: emailSettings, },
}, });
}); };
if (distributionMethod === DocumentDistributionMethod.EMAIL) { const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
return sendDocument({
documentId: document.id,
meta: {
subject,
message,
distributionMethod,
emailId,
emailReplyTo: emailReplyTo || null,
emailSettings,
},
});
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
try {
await sendDocumentWithSubject(data);
if (data.meta.distributionMethod === DocumentDistributionMethod.EMAIL) {
toast({ toast({
title: _(msg`Document sent`), title: _(msg`Document sent`),
description: _(msg`Your document has been sent successfully.`), description: _(msg`Your document has been sent successfully.`),
@@ -304,6 +379,21 @@ export const DocumentEditForm = ({
} }
}; };
const onAddSubjectFormAutoSave = async (data: TAddSubjectFormSchema) => {
try {
// Save form data without sending the document
await saveSubjectData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the subject form.`),
variant: 'destructive',
});
}
};
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
/** /**
@@ -349,25 +439,28 @@ export const DocumentEditForm = ({
fields={fields} fields={fields}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit} onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
/> />
<AddSignersFormPartial <AddSignersFormPartial
key={recipients.length} key={document.id}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
signingOrder={document.documentMeta?.signingOrder} signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields} fields={fields}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddFieldsFormPartial <AddFieldsFormPartial
key={fields.length} key={document.id}
documentFlow={documentFlow.fields} documentFlow={documentFlow.fields}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
teamId={team.id} teamId={team.id}
/> />
@@ -379,6 +472,7 @@ export const DocumentEditForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
onAutoSave={onAddSubjectFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
</Stepper> </Stepper>
@@ -124,32 +124,36 @@ export const TemplateEditForm = ({
}, },
}); });
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta; const { signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema) .array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth); .safeParse(data.globalAccessAuth);
return updateTemplateSettings({
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
};
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try { try {
await updateTemplateSettings({ await saveSettingsData(data);
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
setStep('signers'); setStep('signers');
} catch (err) { } catch (err) {
@@ -163,24 +167,42 @@ export const TemplateEditForm = ({
} }
}; };
const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
try {
await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template settings.`),
variant: 'destructive',
});
}
};
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => {
return Promise.all([
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers,
}),
]);
};
const onAddTemplatePlaceholderFormSubmit = async ( const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema, data: TAddTemplatePlacholderRecipientsFormSchema,
) => { ) => {
try { try {
await Promise.all([ await saveTemplatePlaceholderData(data);
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers,
}),
]);
setStep('fields'); setStep('fields');
} catch (err) { } catch (err) {
@@ -192,12 +214,46 @@ export const TemplateEditForm = ({
} }
}; };
const onAddTemplatePlaceholderFormAutoSave = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await saveTemplatePlaceholderData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template placeholders.`),
variant: 'destructive',
});
}
};
const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => {
return addTemplateFields({
templateId: template.id,
fields: data.fields,
});
};
const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormSchema) => {
try {
await saveFieldsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template fields.`),
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try { try {
await addTemplateFields({ await saveFieldsData(data);
templateId: template.id,
fields: data.fields,
});
// Clear all field data from localStorage // Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
@@ -270,11 +326,12 @@ export const TemplateEditForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddSettingsFormSubmit} onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddTemplatePlaceholderRecipientsFormPartial <AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length} key={template.id}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
@@ -282,15 +339,17 @@ export const TemplateEditForm = ({
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner} allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink} templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddTemplateFieldsFormPartial <AddTemplateFieldsFormPartial
key={fields.length} key={template.id}
documentFlow={documentFlow.fields} documentFlow={documentFlow.fields}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
teamId={team?.id} teamId={team?.id}
/> />
</Stepper> </Stepper>
@@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { FolderGrid } from '~/components/general/folder/folder-grid'; import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table'; import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
export function meta() { export function meta() {
return appMetaTags('Templates'); return appMetaTags('Templates');
@@ -0,0 +1,293 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Fields Step', () => {
test('should autosave the fields without advanced settings', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(3);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field deletion', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field duplication', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(4);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
expect(retrievedFields[3].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the fields with advanced settings', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
const textField = retrievedFields[1];
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
if (textField.fieldMeta.type === 'text') {
expect(textField.fieldMeta.text).toBe('Test Text');
}
} else {
throw new Error('fieldMeta should be defined and contain advanced settings');
}
}).toPass();
});
});
@@ -0,0 +1,243 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupDocument = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Settings Step', () => {
test('should autosave the title change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newDocumentTitle = 'New Document Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newDocumentTitle);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrieved.title);
}).toPass();
});
test('should autosave the language change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newDocumentLanguage = 'French';
const expectedLanguageCode = 'fr';
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: newDocumentLanguage }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.language).toBe(expectedLanguageCode);
}).toPass();
});
test('should autosave the document access change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const access = 'Require account';
const accessValue = 'ACCOUNT';
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: access }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.authOptions?.globalAccessAuth).toContain(accessValue);
}).toPass();
});
test('should autosave the external ID change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newExternalId = '1234567890';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.externalId).toBe(newExternalId);
}).toPass();
});
test('should autosave the allowed signature types change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(3).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
}).toPass();
});
test('should autosave the date format change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}).toPass();
});
test('should autosave the timezone change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.timezone).toBe('Europe/London');
}).toPass();
});
test('should autosave the redirect URL change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newRedirectUrl = 'https://documenso.com/test/';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.redirectUrl).toBe(newRedirectUrl);
}).toPass();
});
test('should autosave multiple field changes together', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newTitle = 'Updated Document Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'Require account' }).click();
await page.getByRole('button', { name: 'Advanced Options' }).click();
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.title).toBe(newTitle);
expect(retrieved.documentMeta?.language).toBe('de');
expect(retrieved.authOptions?.globalAccessAuth).toContain('ACCOUNT');
expect(retrieved.externalId).toBe(newExternalId);
expect(retrieved.documentMeta?.timezone).toBe('Europe/Berlin');
}).toPass();
});
});
@@ -0,0 +1,168 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
const addSignerAndSave = async (page: Page) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await triggerAutosave(page);
};
test.describe('AutoSave Signers Step', () => {
test('should autosave the signers addition', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
expect(retrievedRecipients[0].name).toBe('Recipient 1');
}).toPass();
});
test('should autosave the signer deletion', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add myself' }).click();
await triggerAutosave(page);
await page.getByTestId('remove-signer-button').first().click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe(user.email);
expect(retrievedRecipients[0].name).toBe(user.name);
}).toPass();
});
test('should autosave the signer update', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByPlaceholder('Name').fill('Documenso Manager');
await page.getByPlaceholder('Email').fill('manager@documenso.com');
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
expect(retrievedRecipients[0].role).toBe('CC');
}).toPass();
});
test('should autosave the signing order change', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByTestId('signer-email-input').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByTestId('signer-email-input').nth(2).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(2).fill('Recipient 3');
await triggerAutosave(page);
await page.getByLabel('Enable signing order').check();
await page.getByLabel('Allow signers to dictate next signer').check();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(0).fill('3');
await page.getByTestId('signing-order-input').nth(0).blur();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(1).fill('1');
await page.getByTestId('signing-order-input').nth(1).blur();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(2).fill('2');
await page.getByTestId('signing-order-input').nth(2).blur();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});
@@ -0,0 +1,200 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
return { user, team, document };
};
export const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Subject Step', () => {
test('should autosave the subject field', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const subject = 'Hello world!';
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.subject ?? '',
);
}).toPass();
});
test('should autosave the message field', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const message = 'Please review and sign this important document. Thank you!';
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.message ?? '',
);
}).toPass();
});
test('should autosave the email settings checkboxes', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
await expect(page.getByText('Send recipient signed email')).toBeChecked({
checked: emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
checked: emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
checked: emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
checked: emailSettings?.ownerDocumentCompleted,
});
}).toPass();
});
test('should autosave all fields and settings together', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const subject = 'Combined Test Subject - Please Sign';
const message =
'This is a comprehensive test message for autosave functionality. Please review and sign at your earliest convenience.';
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedDocumentData.documentMeta?.subject).toBe(subject);
expect(retrievedDocumentData.documentMeta?.message).toBe(message);
expect(retrievedDocumentData.documentMeta?.emailSettings).toBeDefined();
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.subject ?? '',
);
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.message ?? '',
);
await expect(page.getByText('Send recipient signed email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
});
}).toPass();
});
});
@@ -534,9 +534,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.getByLabel('Title').fill(documentTitle); await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
if (i > 1) { if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
@@ -558,6 +555,9 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
.fill(`User ${i}`); .fill(`User ${i}`);
} }
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
@@ -0,0 +1,304 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Fields Step', () => {
test('should autosave the fields without advanced settings', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(3);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
expect(fields[2].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field deletion', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(2);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field duplication', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(4);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
expect(fields[2].type).toBe('SIGNATURE');
expect(fields[3].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the fields with advanced settings', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await page.waitForTimeout(2500);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedTemplate.fields;
expect(fields.length).toBe(2);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
const textField = fields[1];
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
if (textField.fieldMeta.type === 'text') {
expect(textField.fieldMeta.text).toBe('Test Text');
}
} else {
throw new Error('fieldMeta should be defined and contain advanced settings');
}
}).toPass();
});
});
@@ -0,0 +1,244 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupTemplate = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Settings Step - Templates', () => {
test('should autosave the title change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTemplateTitle = 'New Template Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTemplateTitle);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(
retrievedTemplate.title,
);
}).toPass();
});
test('should autosave the language change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTemplateLanguage = 'French';
const expectedLanguageCode = 'fr';
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: newTemplateLanguage }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.language).toBe(expectedLanguageCode);
}).toPass();
});
test('should autosave the template access change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const access = 'Require account';
const accessValue = 'ACCOUNT';
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: access }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain(accessValue);
}).toPass();
});
test('should autosave the external ID change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newExternalId = '1234567890';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.externalId).toBe(newExternalId);
}).toPass();
});
test('should autosave the allowed signature types change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
}).toPass();
});
test('should autosave the date format change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}).toPass();
});
test('should autosave the timezone change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/London');
}).toPass();
});
test('should autosave the redirect URL change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newRedirectUrl = 'https://documenso.com/test/';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.redirectUrl).toBe(newRedirectUrl);
}).toPass();
});
test('should autosave multiple field changes together', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTitle = 'Updated Template Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'Require account' }).click();
await page.getByRole('button', { name: 'Advanced Options' }).click();
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.title).toBe(newTitle);
expect(retrievedTemplate.templateMeta?.language).toBe('de');
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain('ACCOUNT');
expect(retrievedTemplate.externalId).toBe(newExternalId);
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/Berlin');
}).toPass();
});
});
@@ -0,0 +1,174 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
const addSignerAndSave = async (page: Page) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await triggerAutosave(page);
};
test.describe('AutoSave Signers Step - Templates', () => {
test('should autosave the signers addition', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
expect(retrievedRecipients[0].name).toBe('Recipient 1');
}).toPass();
});
test('should autosave the signer deletion', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add myself' }).click();
await triggerAutosave(page);
await page.getByTestId('remove-placeholder-recipient-button').first().click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe(user.email);
expect(retrievedRecipients[0].name).toBe(user.name);
}).toPass();
});
test('should autosave the signer update', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByPlaceholder('Name').fill('Documenso Manager');
await page.getByPlaceholder('Email').fill('manager@documenso.com');
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
expect(retrievedRecipients[0].role).toBe('CC');
}).toPass();
});
test('should autosave the signing order change', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(1)
.fill('recipient2@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(2)
.fill('recipient3@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3');
await triggerAutosave(page);
await page.getByLabel('Enable signing order').check();
await page.getByLabel('Allow signers to dictate next signer').check();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).fill('3');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).blur();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).fill('1');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).blur();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).fill('2');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).blur();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedTemplate.templateMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ export default defineConfig({
testDir: './e2e', testDir: './e2e',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 4,
maxFailures: process.env.CI ? 1 : undefined, maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
@@ -0,0 +1,31 @@
import { useCallback, useEffect, useRef } from 'react';
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveFormData = async (data: T) => {
try {
await onSave(data);
} catch (error) {
console.error('Auto-save failed:', error);
}
};
const scheduleSave = useCallback((data: T) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
}, []);
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
return { scheduleSave };
};
@@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface GetRecipientsForTemplateOptions { export interface GetRecipientsForTemplateOptions {
templateId: number; templateId: number;
userId: number; userId: number;
@@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
where: { where: {
templateId, templateId,
template: teamId template: {
? { team: buildTeamWhereQuery({
team: { teamId,
id: teamId, userId,
members: { }),
some: { },
userId,
},
},
},
}
: {
userId,
teamId: null,
},
}, },
orderBy: { orderBy: {
id: 'asc', id: 'asc',
@@ -21,6 +21,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
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 { import {
@@ -83,6 +84,7 @@ export type AddFieldsFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void; onSubmit: (_data: TAddFieldsFormSchema) => void;
onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>;
canGoBack?: boolean; canGoBack?: boolean;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
teamId: number; teamId: number;
@@ -94,6 +96,7 @@ export const AddFieldsFormPartial = ({
recipients, recipients,
fields, fields,
onSubmit, onSubmit,
onAutoSave,
canGoBack = false, canGoBack = false,
isDocumentPdfLoaded, isDocumentPdfLoaded,
teamId, teamId,
@@ -590,6 +593,20 @@ export const AddFieldsFormPartial = ({
} }
}; };
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
return ( return (
<> <>
{showAdvancedSettings && currentField ? ( {showAdvancedSettings && currentField ? (
@@ -603,7 +620,14 @@ export const AddFieldsFormPartial = ({
fields={localFields} fields={localFields}
onAdvancedSettings={handleAdvancedSettings} onAdvancedSettings={handleAdvancedSettings}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
onSave={handleSavedFieldSettings} onSave={(fieldState) => {
handleSavedFieldSettings(fieldState);
void handleAutoSave();
}}
onAutoSave={async (fieldState) => {
handleSavedFieldSettings(fieldState);
await handleAutoSave();
}}
/> />
) : ( ) : (
<> <>
@@ -660,14 +684,26 @@ export const AddFieldsFormPartial = ({
defaultWidth={DEFAULT_WIDTH_PX} defaultWidth={DEFAULT_WIDTH_PX}
passive={isFieldWithinBounds && !!selectedField} passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)} onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)} onBlur={() => {
setLastActiveField(null);
void handleAutoSave();
}}
onMouseEnter={() => setLastActiveField(field)} onMouseEnter={() => setLastActiveField(field)}
onMouseLeave={() => setLastActiveField(null)} onMouseLeave={() => setLastActiveField(null)}
onResize={(options) => onFieldResize(options, index)} onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)} onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)} onRemove={() => {
onDuplicate={() => onFieldCopy(null, { duplicate: true })} remove(index);
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} void handleAutoSave();
}}
onDuplicate={() => {
onFieldCopy(null, { duplicate: true });
void handleAutoSave();
}}
onDuplicateAllPages={() => {
onFieldCopy(null, { duplicateAll: true });
void handleAutoSave();
}}
onAdvancedSettings={() => { onAdvancedSettings={() => {
setCurrentField(field); setCurrentField(field);
handleAdvancedSettings(); handleAdvancedSettings();
@@ -14,6 +14,7 @@ import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document'; import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
@@ -79,6 +80,7 @@ export type AddSettingsFormProps = {
document: TDocument; document: TDocument;
currentTeamMemberRole?: TeamMemberRole; currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddSettingsFormSchema) => void; onSubmit: (_data: TAddSettingsFormSchema) => void;
onAutoSave: (_data: TAddSettingsFormSchema) => Promise<void>;
}; };
export const AddSettingsFormPartial = ({ export const AddSettingsFormPartial = ({
@@ -89,6 +91,7 @@ export const AddSettingsFormPartial = ({
document, document,
currentTeamMemberRole, currentTeamMemberRole,
onSubmit, onSubmit,
onAutoSave,
}: AddSettingsFormProps) => { }: AddSettingsFormProps) => {
const { t } = useLingui(); const { t } = useLingui();
@@ -161,6 +164,28 @@ export const AddSettingsFormPartial = ({
document.documentMeta?.timezone, document.documentMeta?.timezone,
]); ]);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
/*
* Parse the form data through the Zod schema to handle transformations
* (like -1 -> undefined for the Document Global Auth Access)
*/
const parseResult = ZAddSettingsFormSchema.safeParse(formData);
if (parseResult.success) {
scheduleSave(parseResult.data);
}
};
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@@ -196,6 +221,7 @@ export const AddSettingsFormPartial = ({
className="bg-background" className="bg-background"
{...field} {...field}
disabled={document.status !== DocumentStatus.DRAFT || field.disabled} disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
onBlur={handleAutoSave}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -227,9 +253,13 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<Select <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
> >
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
<SelectValue /> <SelectValue />
@@ -261,9 +291,13 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<DocumentGlobalAuthAccessSelect <DocumentGlobalAuthAccessSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -286,7 +320,10 @@ export const AddSettingsFormPartial = ({
canUpdateVisibility={canUpdateVisibility} canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole} currentTeamMemberRole={currentTeamMemberRole}
{...field} {...field}
onValueChange={field.onChange} onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -307,9 +344,13 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<DocumentGlobalAuthActionSelect <DocumentGlobalAuthActionSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -347,7 +388,7 @@ export const AddSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -372,7 +413,10 @@ export const AddSettingsFormPartial = ({
value: option.value, value: option.value,
}))} }))}
selectedValues={field.value} selectedValues={field.value}
onChange={field.onChange} onChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
className="bg-background w-full" className="bg-background w-full"
emptySelectionPlaceholder="Select signature types" emptySelectionPlaceholder="Select signature types"
/> />
@@ -394,8 +438,12 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<Select <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
onValueChange={field.onChange}
disabled={documentHasBeenSent} disabled={documentHasBeenSent}
> >
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
@@ -430,8 +478,12 @@ export const AddSettingsFormPartial = ({
<Combobox <Combobox
className="bg-background" className="bg-background"
options={TIME_ZONES} options={TIME_ZONES}
{...field}
onChange={(value) => {
value && field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent} disabled={documentHasBeenSent}
/> />
</FormControl> </FormControl>
@@ -462,7 +514,7 @@ export const AddSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
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 { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
@@ -55,6 +56,7 @@ export type AddSignersFormProps = {
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean; allowDictateNextSigner?: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void; onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@@ -65,6 +67,7 @@ export const AddSignersFormPartial = ({
signingOrder, signingOrder,
allowDictateNextSigner, allowDictateNextSigner,
onSubmit, onSubmit,
onAutoSave,
isDocumentPdfLoaded, isDocumentPdfLoaded,
}: AddSignersFormProps) => { }: AddSignersFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@@ -166,6 +169,29 @@ export const AddSignersFormPartial = ({
name: 'signers', name: 'signers',
}); });
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
if (emptySigners().length > 0) {
return;
}
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email); const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some( const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(), (signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
@@ -216,24 +242,47 @@ export const AddSignersFormPartial = ({
const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId); const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId);
if (formStateIndex !== -1) { if (formStateIndex !== -1) {
removeSigner(formStateIndex); removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId); const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
form.setValue('signers', normalizeSigningOrders(updatedSigners));
form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
} }
}; };
const onAddSelfSigner = () => { const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) { if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? ''); setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', {
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? ''); shouldValidate: true,
} else { shouldDirty: true,
appendSigner({
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
}); });
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', {
shouldValidate: true,
shouldDirty: true,
});
form.setFocus(`signers.${emptySignerIndex}.email`);
} else {
appendSigner(
{
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder:
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
},
{
shouldFocus: true,
},
);
void form.trigger('signers');
} }
}; };
@@ -263,7 +312,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1, signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
const lastSigner = updatedSigners[updatedSigners.length - 1]; const lastSigner = updatedSigners[updatedSigners.length - 1];
if (lastSigner.role === RecipientRole.ASSISTANT) { if (lastSigner.role === RecipientRole.ASSISTANT) {
@@ -276,8 +328,10 @@ export const AddSignersFormPartial = ({
} }
await form.trigger('signers'); await form.trigger('signers');
void handleAutoSave();
}, },
[form, canRecipientBeModified, watchedSigners, toast], [form, canRecipientBeModified, watchedSigners, handleAutoSave, toast],
); );
const handleRoleChange = useCallback( const handleRoleChange = useCallback(
@@ -287,7 +341,10 @@ export const AddSignersFormPartial = ({
// Handle parallel to sequential conversion for assistants // Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({ toast({
title: _(msg`Signing order is enabled.`), title: _(msg`Signing order is enabled.`),
description: _(msg`You cannot add assistants when signing order is disabled.`), description: _(msg`You cannot add assistants when signing order is disabled.`),
@@ -302,7 +359,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1, signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
toast({ toast({
@@ -341,7 +401,10 @@ export const AddSignersFormPartial = ({
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1, signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
toast({ toast({
@@ -364,9 +427,20 @@ export const AddSignersFormPartial = ({
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); shouldValidate: true,
form.setValue('allowDictateNextSigner', false); shouldDirty: true,
});
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}, [form]); }, [form]);
return ( return (
@@ -408,19 +482,39 @@ export const AddSignersFormPartial = ({
// If sequential signing is turned off, disable dictate next signer // If sequential signing is turned off, disable dictate next signer
if (!checked) { if (!checked) {
form.setValue('allowDictateNextSigner', false); form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
} }
void handleAutoSave();
}} }}
disabled={isSubmitting || hasDocumentBeenSent} disabled={isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0}
/> />
</FormControl> </FormControl>
<FormLabel <div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
htmlFor="signingOrder" <FormLabel
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" htmlFor="signingOrder"
> className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
<Trans>Enable signing order</Trans> >
</FormLabel> <Trans>Enable signing order</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>Add 2 or more signers to enable signing order.</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem> </FormItem>
)} )}
/> />
@@ -435,12 +529,15 @@ export const AddSignersFormPartial = ({
{...field} {...field}
id="allowDictateNextSigner" id="allowDictateNextSigner"
checked={value} checked={value}
onCheckedChange={field.onChange} onCheckedChange={(checked) => {
field.onChange(checked);
void handleAutoSave();
}}
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential} disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
/> />
</FormControl> </FormControl>
<div className="flex items-center"> <div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel <FormLabel
htmlFor="allowDictateNextSigner" htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
@@ -533,6 +630,7 @@ export const AddSignersFormPartial = ({
<Input <Input
type="number" type="number"
max={signers.length} max={signers.length}
data-testid="signing-order-input"
className={cn( className={cn(
'w-full text-center', 'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
@@ -541,10 +639,12 @@ export const AddSignersFormPartial = ({
onChange={(e) => { onChange={(e) => {
field.onChange(e); field.onChange(e);
handleSigningOrderChange(index, e.target.value); handleSigningOrderChange(index, e.target.value);
void handleAutoSave();
}} }}
onBlur={(e) => { onBlur={(e) => {
field.onBlur(); field.onBlur();
handleSigningOrderChange(index, e.target.value); handleSigningOrderChange(index, e.target.value);
void handleAutoSave();
}} }}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
@@ -588,7 +688,9 @@ export const AddSignersFormPartial = ({
isSubmitting || isSubmitting ||
!canRecipientBeModified(signer.nativeId) !canRecipientBeModified(signer.nativeId)
} }
data-testid="signer-email-input"
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={handleAutoSave}
/> />
</FormControl> </FormControl>
@@ -626,6 +728,7 @@ export const AddSignersFormPartial = ({
!canRecipientBeModified(signer.nativeId) !canRecipientBeModified(signer.nativeId)
} }
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={handleAutoSave}
/> />
</FormControl> </FormControl>
@@ -668,6 +771,7 @@ export const AddSignersFormPartial = ({
<div className="col-span-2 flex gap-x-2"> <div className="col-span-2 flex gap-x-2">
<FormField <FormField
control={form.control}
name={`signers.${index}.role`} name={`signers.${index}.role`}
render={({ field }) => ( render={({ field }) => (
<FormItem <FormItem
@@ -681,10 +785,11 @@ export const AddSignersFormPartial = ({
<RecipientRoleSelect <RecipientRoleSelect
{...field} {...field}
isAssistantEnabled={isSigningOrderSequential} isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole) handleRoleChange(index, value as RecipientRole);
} void handleAutoSave();
}}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||
@@ -706,6 +811,7 @@ export const AddSignersFormPartial = ({
'mb-6': form.formState.errors.signers?.[index], 'mb-6': form.formState.errors.signers?.[index],
}, },
)} )}
data-testid="remove-signer-button"
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||
@@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
@@ -8,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDocument } from '@documenso/lib/types/document'; import type { TDocument } from '@documenso/lib/types/document';
@@ -60,6 +63,7 @@ export type AddSubjectFormProps = {
fields: Field[]; fields: Field[];
document: TDocument; document: TDocument;
onSubmit: (_data: TAddSubjectFormSchema) => void; onSubmit: (_data: TAddSubjectFormSchema) => void;
onAutoSave: (_data: TAddSubjectFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@@ -69,6 +73,7 @@ export const AddSubjectFormPartial = ({
fields: fields, fields: fields,
document, document,
onSubmit, onSubmit,
onAutoSave,
isDocumentPdfLoaded, isDocumentPdfLoaded,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@@ -95,6 +100,8 @@ export const AddSubjectFormPartial = ({
handleSubmit, handleSubmit,
setValue, setValue,
watch, watch,
trigger,
getValues,
formState: { isSubmitting }, formState: { isSubmitting },
} = form; } = form;
@@ -129,6 +136,35 @@ export const AddSubjectFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep(); const { currentStep, totalSteps, previousStep } = useStep();
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await trigger();
if (!isFormValid) {
return;
}
const formData = getValues();
scheduleSave(formData);
};
useEffect(() => {
const container = window.document.getElementById('document-flow-form-container');
const handleBlur = () => {
void handleAutoSave();
};
if (container) {
container.addEventListener('blur', handleBlur, true);
return () => {
container.removeEventListener('blur', handleBlur, true);
};
}
}, []);
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@@ -185,7 +221,6 @@ export const AddSubjectFormPartial = ({
<FormLabel> <FormLabel>
<Trans>Email Sender</Trans> <Trans>Email Sender</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Select
{...field} {...field}
@@ -6,6 +6,7 @@ import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client'; import { FieldType } from '@prisma/client';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { import {
type TBaseFieldMeta as BaseFieldMeta, type TBaseFieldMeta as BaseFieldMeta,
type TCheckboxFieldMeta as CheckboxFieldMeta, type TCheckboxFieldMeta as CheckboxFieldMeta,
@@ -48,6 +49,7 @@ export type FieldAdvancedSettingsProps = {
onAdvancedSettings?: () => void; onAdvancedSettings?: () => void;
isDocumentPdfLoaded?: boolean; isDocumentPdfLoaded?: boolean;
onSave?: (fieldState: FieldMeta) => void; onSave?: (fieldState: FieldMeta) => void;
onAutoSave?: (fieldState: FieldMeta) => Promise<void>;
}; };
export type FieldMetaKeys = export type FieldMetaKeys =
@@ -146,7 +148,16 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>( export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>(
( (
{ title, description, field, fields, onAdvancedSettings, isDocumentPdfLoaded = true, onSave }, {
title,
description,
field,
fields,
onAdvancedSettings,
isDocumentPdfLoaded = true,
onSave,
onAutoSave,
},
ref, ref,
) => { ) => {
const { _ } = useLingui(); const { _ } = useLingui();
@@ -177,6 +188,24 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldMeta]); }, [fieldMeta]);
const { scheduleSave } = useAutoSave(onAutoSave || (async () => {}));
const handleAutoSave = () => {
if (errors.length === 0) {
scheduleSave(fieldState);
}
};
// Auto-save to localStorage and schedule remote save when fieldState changes
useEffect(() => {
try {
localStorage.setItem(localStorageKey, JSON.stringify(fieldState));
handleAutoSave();
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
}, [fieldState, localStorageKey, handleAutoSave]);
const handleFieldChange = ( const handleFieldChange = (
key: FieldMetaKeys, key: FieldMetaKeys,
value: value:
@@ -325,7 +354,10 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
)} )}
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter className="mt-auto"> <DocumentFlowFormContainerFooter
className="mt-auto"
data-testid="field-advanced-settings-footer"
>
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
goNextLabel={msg`Save`} goNextLabel={msg`Save`}
goBackLabel={msg`Cancel`} goBackLabel={msg`Cancel`}
@@ -21,6 +21,7 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
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';
@@ -73,6 +74,7 @@ export type AddTemplateFieldsFormProps = {
recipients: Recipient[]; recipients: Recipient[];
fields: Field[]; fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>;
teamId: number; teamId: number;
}; };
@@ -81,6 +83,7 @@ export const AddTemplateFieldsFormPartial = ({
recipients, recipients,
fields, fields,
onSubmit, onSubmit,
onAutoSave,
teamId, teamId,
}: AddTemplateFieldsFormProps) => { }: AddTemplateFieldsFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@@ -121,6 +124,20 @@ export const AddTemplateFieldsFormPartial = ({
const onFormSubmit = form.handleSubmit(onSubmit); const onFormSubmit = form.handleSubmit(onSubmit);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
const { const {
append, append,
remove, remove,
@@ -160,6 +177,7 @@ export const AddTemplateFieldsFormPartial = ({
}; };
append(newField); append(newField);
void handleAutoSave();
return; return;
} }
@@ -187,6 +205,7 @@ export const AddTemplateFieldsFormPartial = ({
append(newField); append(newField);
}); });
void handleAutoSave();
return; return;
} }
@@ -198,7 +217,15 @@ export const AddTemplateFieldsFormPartial = ({
}); });
} }
}, },
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast], [
append,
lastActiveField,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
toast,
handleAutoSave,
],
); );
const onFieldPaste = useCallback( const onFieldPaste = useCallback(
@@ -218,9 +245,18 @@ export const AddTemplateFieldsFormPartial = ({
pageX: copiedField.pageX + 3, pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3, pageY: copiedField.pageY + 3,
}); });
void handleAutoSave();
} }
}, },
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token], [
append,
fieldClipboard,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
handleAutoSave,
],
); );
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt)); useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
@@ -378,8 +414,10 @@ export const AddTemplateFieldsFormPartial = ({
pageWidth, pageWidth,
pageHeight, pageHeight,
}); });
void handleAutoSave();
}, },
[getFieldPosition, localFields, update], [getFieldPosition, localFields, update, handleAutoSave],
); );
const onFieldMove = useCallback( const onFieldMove = useCallback(
@@ -401,8 +439,10 @@ export const AddTemplateFieldsFormPartial = ({
pageX, pageX,
pageY, pageY,
}); });
void handleAutoSave();
}, },
[getFieldPosition, localFields, update], [getFieldPosition, localFields, update, handleAutoSave],
); );
useEffect(() => { useEffect(() => {
@@ -504,6 +544,7 @@ export const AddTemplateFieldsFormPartial = ({
}); });
form.setValue('fields', updatedFields); form.setValue('fields', updatedFields);
void handleAutoSave();
}; };
return ( return (
@@ -519,6 +560,10 @@ export const AddTemplateFieldsFormPartial = ({
fields={localFields} fields={localFields}
onAdvancedSettings={handleAdvancedSettings} onAdvancedSettings={handleAdvancedSettings}
onSave={handleSavedFieldSettings} onSave={handleSavedFieldSettings}
onAutoSave={async (fieldState) => {
handleSavedFieldSettings(fieldState);
await handleAutoSave();
}}
/> />
) : ( ) : (
<> <>
@@ -566,12 +611,22 @@ export const AddTemplateFieldsFormPartial = ({
defaultWidth={DEFAULT_WIDTH_PX} defaultWidth={DEFAULT_WIDTH_PX}
passive={isFieldWithinBounds && !!selectedField} passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)} onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)} onBlur={() => {
setLastActiveField(null);
void handleAutoSave();
}}
onResize={(options) => onFieldResize(options, index)} onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)} onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)} onRemove={() => {
onDuplicate={() => onFieldCopy(null, { duplicate: true })} remove(index);
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} void handleAutoSave();
}}
onDuplicate={() => {
onFieldCopy(null, { duplicate: true });
}}
onDuplicateAllPages={() => {
onFieldCopy(null, { duplicateAll: true });
}}
onAdvancedSettings={() => { onAdvancedSettings={() => {
setCurrentField(field); setCurrentField(field);
handleAdvancedSettings(); handleAdvancedSettings();
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useCallback, useId, useMemo, useRef, useState } from 'react';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd'; import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
@@ -12,6 +12,7 @@ import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react'; import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
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 { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
@@ -55,6 +56,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
allowDictateNextSigner?: boolean; allowDictateNextSigner?: boolean;
templateDirectLink?: TemplateDirectLink | null; templateDirectLink?: TemplateDirectLink | null;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<void>;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
}; };
@@ -67,6 +69,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
allowDictateNextSigner, allowDictateNextSigner,
isDocumentPdfLoaded, isDocumentPdfLoaded,
onSubmit, onSubmit,
onAutoSave,
}: AddTemplatePlaceholderRecipientsFormProps) => { }: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId(); const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null); const $sensorApi = useRef<SensorAPI | null>(null);
@@ -123,15 +126,38 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
}, },
}); });
useEffect(() => { const emptySigners = useCallback(
form.reset({ () => form.getValues('signers').filter((signer) => signer.email === ''),
signers: generateDefaultFormSigners(), [form],
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, );
allowDictateNextSigner: allowDictateNextSigner ?? false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps const { scheduleSave } = useAutoSave(onAutoSave);
}, [recipients]);
const handleAutoSave = async () => {
if (emptySigners().length > 0) {
return;
}
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
scheduleSave(formData);
};
// useEffect(() => {
// form.reset({
// signers: generateDefaultFormSigners(),
// signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
// allowDictateNextSigner: allowDictateNextSigner ?? false,
// });
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [recipients]);
// Always show advanced settings if any recipient has auth options. // Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => { const alwaysShowAdvancedSettings = useMemo(() => {
@@ -204,7 +230,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const onRemoveSigner = (index: number) => { const onRemoveSigner = (index: number) => {
removeSigner(index); removeSigner(index);
const updatedSigners = signers.filter((_, idx) => idx !== index); const updatedSigners = signers.filter((_, idx) => idx !== index);
form.setValue('signers', normalizeSigningOrders(updatedSigners)); form.setValue('signers', normalizeSigningOrders(updatedSigners), {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}; };
const isSignerDirectRecipient = ( const isSignerDirectRecipient = (
@@ -231,7 +262,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: index + 1, signingOrder: index + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
const lastSigner = updatedSigners[updatedSigners.length - 1]; const lastSigner = updatedSigners[updatedSigners.length - 1];
if (lastSigner.role === RecipientRole.ASSISTANT) { if (lastSigner.role === RecipientRole.ASSISTANT) {
@@ -244,8 +278,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
} }
await form.trigger('signers'); await form.trigger('signers');
void handleAutoSave();
}, },
[form, watchedSigners, toast], [form, watchedSigners, toast, handleAutoSave],
); );
const handleSigningOrderChange = useCallback( const handleSigningOrderChange = useCallback(
@@ -273,7 +309,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: idx + 1, signingOrder: idx + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
toast({ toast({
@@ -283,8 +322,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
), ),
}); });
} }
void handleAutoSave();
}, },
[form, toast], [form, toast, handleAutoSave],
); );
const handleRoleChange = useCallback( const handleRoleChange = useCallback(
@@ -294,7 +335,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
// Handle parallel to sequential conversion for assistants // Handle parallel to sequential conversion for assistants
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({ toast({
title: _(msg`Signing order is enabled.`), title: _(msg`Signing order is enabled.`),
description: _(msg`You cannot add assistants when signing order is disabled.`), description: _(msg`You cannot add assistants when signing order is disabled.`),
@@ -309,7 +353,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signingOrder: idx + 1, signingOrder: idx + 1,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
toast({ toast({
@@ -319,8 +366,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
), ),
}); });
} }
void handleAutoSave();
}, },
[form, toast], [form, toast, handleAutoSave],
); );
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
@@ -334,10 +383,21 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
})); }));
form.setValue('signers', updatedSigners); form.setValue('signers', updatedSigners, {
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); shouldValidate: true,
form.setValue('allowDictateNextSigner', false); shouldDirty: true,
}, [form]); });
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
void handleAutoSave();
}, [form, handleAutoSave]);
return ( return (
<> <>
@@ -382,8 +442,13 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
// If sequential signing is turned off, disable dictate next signer // If sequential signing is turned off, disable dictate next signer
if (!checked) { if (!checked) {
form.setValue('allowDictateNextSigner', false); form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
} }
void handleAutoSave();
}} }}
disabled={isSubmitting} disabled={isSubmitting}
/> />
@@ -409,7 +474,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{...field} {...field}
id="allowDictateNextSigner" id="allowDictateNextSigner"
checked={value} checked={value}
onCheckedChange={field.onChange} onCheckedChange={(checked) => {
field.onChange(checked);
void handleAutoSave();
}}
disabled={isSubmitting || !isSigningOrderSequential} disabled={isSubmitting || !isSigningOrderSequential}
/> />
</FormControl> </FormControl>
@@ -500,6 +568,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Input <Input
type="number" type="number"
max={signers.length} max={signers.length}
data-testid="placeholder-recipient-signing-order-input"
className={cn( className={cn(
'w-full text-center', 'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
@@ -558,6 +627,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signers[index].email === user?.email || signers[index].email === user?.email ||
isSignerDirectRecipient(signer) isSignerDirectRecipient(signer)
} }
onBlur={handleAutoSave}
data-testid="placeholder-recipient-email-input"
/> />
</FormControl> </FormControl>
@@ -592,6 +663,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
signers[index].email === user?.email || signers[index].email === user?.email ||
isSignerDirectRecipient(signer) isSignerDirectRecipient(signer)
} }
onBlur={handleAutoSave}
data-testid="placeholder-recipient-name-input"
/> />
</FormControl> </FormControl>
@@ -633,10 +706,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<FormControl> <FormControl>
<RecipientRoleSelect <RecipientRoleSelect
{...field} {...field}
onValueChange={(value) => onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole) handleRoleChange(index, value as RecipientRole);
} }}
disabled={isSubmitting} disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)} hideCCRecipients={isSignerDirectRecipient(signer)}
/> />
@@ -672,6 +745,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1} disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)} onClick={() => onRemoveSigner(index)}
data-testid="remove-placeholder-recipient-button"
> >
<Trash className="h-5 w-5" /> <Trash className="h-5 w-5" />
</button> </button>
@@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { import {
@@ -83,6 +84,7 @@ export type AddTemplateSettingsFormProps = {
template: TTemplate; template: TTemplate;
currentTeamMemberRole?: TeamMemberRole; currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void; onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
onAutoSave: (_data: TAddTemplateSettingsFormSchema) => Promise<void>;
}; };
export const AddTemplateSettingsFormPartial = ({ export const AddTemplateSettingsFormPartial = ({
@@ -93,6 +95,7 @@ export const AddTemplateSettingsFormPartial = ({
template, template,
currentTeamMemberRole, currentTeamMemberRole,
onSubmit, onSubmit,
onAutoSave,
}: AddTemplateSettingsFormProps) => { }: AddTemplateSettingsFormProps) => {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
@@ -160,6 +163,28 @@ export const AddTemplateSettingsFormPartial = ({
} }
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]); }, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
const { scheduleSave } = useAutoSave(onAutoSave);
const handleAutoSave = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) {
return;
}
const formData = form.getValues();
/*
* Parse the form data through the Zod schema to handle transformations
* (like -1 -> undefined for the Document Global Auth Access)
*/
const parseResult = ZAddTemplateSettingsFormSchema.safeParse(formData);
if (parseResult.success) {
scheduleSave(parseResult.data);
}
};
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@@ -191,7 +216,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -219,7 +244,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -250,9 +281,13 @@ export const AddTemplateSettingsFormPartial = ({
<FormControl> <FormControl>
<DocumentGlobalAuthAccessSelect <DocumentGlobalAuthAccessSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -275,7 +310,10 @@ export const AddTemplateSettingsFormPartial = ({
canUpdateVisibility={canUpdateVisibility} canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole} currentTeamMemberRole={currentTeamMemberRole}
{...field} {...field}
onValueChange={field.onChange} onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -334,7 +372,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background text-muted-foreground"> <SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" /> <SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger> </SelectTrigger>
@@ -371,7 +415,10 @@ export const AddTemplateSettingsFormPartial = ({
value: option.value, value: option.value,
}))} }))}
selectedValues={field.value} selectedValues={field.value}
onChange={field.onChange} onChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
className="bg-background w-full" className="bg-background w-full"
emptySelectionPlaceholder="Select signature types" emptySelectionPlaceholder="Select signature types"
/> />
@@ -395,9 +442,13 @@ export const AddTemplateSettingsFormPartial = ({
<FormControl> <FormControl>
<DocumentGlobalAuthActionSelect <DocumentGlobalAuthActionSelect
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
value={field.value} value={field.value}
disabled={field.disabled} disabled={field.disabled}
onValueChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -488,7 +539,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -515,7 +566,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Textarea className="bg-background h-16 resize-none" {...field} /> <Textarea
className="bg-background h-16 resize-none"
{...field}
onBlur={handleAutoSave}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -525,7 +580,12 @@ export const AddTemplateSettingsFormPartial = ({
<DocumentEmailCheckboxes <DocumentEmailCheckboxes
value={emailSettings} value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)} onChange={(value) => {
form.setValue('meta.emailSettings', value, {
shouldDirty: true,
});
void handleAutoSave();
}}
/> />
</div> </div>
</AccordionContent> </AccordionContent>
@@ -563,7 +623,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -581,7 +641,13 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select
{...field}
onValueChange={(value) => {
field.onChange(value);
void handleAutoSave();
}}
>
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -615,7 +681,10 @@ export const AddTemplateSettingsFormPartial = ({
className="bg-background time-zone-field" className="bg-background time-zone-field"
options={TIME_ZONES} options={TIME_ZONES}
{...field} {...field}
onChange={(value) => value && field.onChange(value)} onChange={(value) => {
value && field.onChange(value);
void handleAutoSave();
}}
/> />
</FormControl> </FormControl>
@@ -645,7 +714,7 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input className="bg-background" {...field} /> <Input className="bg-background" {...field} onBlur={handleAutoSave} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />