From bb5c2edefd909fed06254666ee22da2c442151e2 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 2 Sep 2025 14:01:16 +0300 Subject: [PATCH 1/8] feat: implement auto-save functionality for signers in document edit form (#1792) --- .../general/document/document-edit-form.tsx | 222 +++++++++---- .../general/template/template-edit-form.tsx | 137 +++++--- .../t.$teamUrl+/templates._index.tsx | 2 +- .../autosave-fields-step.spec.ts | 293 +++++++++++++++++ .../autosave-settings-step.spec.ts | 243 ++++++++++++++ .../autosave-signers-step.spec.ts | 168 ++++++++++ .../autosave-subject-step.spec.ts | 200 ++++++++++++ .../document-flow/stepper-component.spec.ts | 6 +- .../template-autosave-fields-step.spec.ts | 304 ++++++++++++++++++ .../template-autosave-settings-step.spec.ts | 244 ++++++++++++++ .../template-autosave-signers-step.spec.ts | 174 ++++++++++ packages/app-tests/playwright.config.ts | 2 +- .../lib/client-only/hooks/use-autosave.ts | 31 ++ .../recipient/get-recipients-for-template.ts | 23 +- .../primitives/document-flow/add-fields.tsx | 46 ++- .../primitives/document-flow/add-settings.tsx | 70 +++- .../primitives/document-flow/add-signers.tsx | 170 ++++++++-- .../primitives/document-flow/add-subject.tsx | 37 ++- .../field-item-advanced-settings.tsx | 36 ++- .../template-flow/add-template-fields.tsx | 71 +++- .../add-template-placeholder-recipients.tsx | 126 ++++++-- .../template-flow/add-template-settings.tsx | 97 +++++- 22 files changed, 2482 insertions(+), 220 deletions(-) create mode 100644 packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts create mode 100644 packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts create mode 100644 packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts create mode 100644 packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts create mode 100644 packages/lib/client-only/hooks/use-autosave.ts diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index df73d92b6..e8ffa5fe5 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -159,34 +159,37 @@ export const DocumentEditForm = ({ 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) => { try { - const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta; - - 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), - }, - }); - + await saveSettingsData(data); setStep('signers'); } catch (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) => { try { - await 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 ?? [], - })), - }), - ]); + await saveSignersData(data); setStep('fields'); } 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) => { try { - await addFields({ - documentId: document.id, - fields: data.fields, - }); + await saveFieldsData(data); // Clear all field data from localStorage 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 } = data.meta; - try { - await sendDocument({ - documentId: document.id, - meta: { - subject, - message, - distributionMethod, - emailId, - emailReplyTo: emailReplyTo || null, - emailSettings: emailSettings, - }, - }); + return updateDocument({ + documentId: document.id, + meta: { + subject, + message, + distributionMethod, + emailId, + emailReplyTo, + 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({ title: _(msg`Document sent`), 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]; /** @@ -349,25 +439,28 @@ export const DocumentEditForm = ({ fields={fields} isDocumentPdfLoaded={isDocumentPdfLoaded} onSubmit={onAddSettingsFormSubmit} + onAutoSave={onAddSettingsFormAutoSave} /> @@ -379,6 +472,7 @@ export const DocumentEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSubjectFormSubmit} + onAutoSave={onAddSubjectFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 3cea126c8..17d7a45a1 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -124,32 +124,36 @@ export const TemplateEditForm = ({ }, }); - const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { + const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => { const { signatureTypes } = data.meta; const parsedGlobalAccessAuth = z .array(ZDocumentAccessAuthTypesSchema) .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 { - await 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, - }, - }); + await saveSettingsData(data); setStep('signers'); } 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 ( data: TAddTemplatePlacholderRecipientsFormSchema, ) => { try { - await Promise.all([ - updateTemplateSettings({ - templateId: template.id, - meta: { - signingOrder: data.signingOrder, - allowDictateNextSigner: data.allowDictateNextSigner, - }, - }), - - setRecipients({ - templateId: template.id, - recipients: data.signers, - }), - ]); + await saveTemplatePlaceholderData(data); setStep('fields'); } 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) => { try { - await addTemplateFields({ - templateId: template.id, - fields: data.fields, - }); + await saveFieldsData(data); // Clear all field data from localStorage for (let i = 0; i < localStorage.length; i++) { @@ -270,11 +326,12 @@ export const TemplateEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSettingsFormSubmit} + onAutoSave={onAddSettingsFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx index bd9f0de99..56e046f13 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx @@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; 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 { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; -import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper'; export function meta() { return appMetaTags('Templates'); diff --git a/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts new file mode 100644 index 000000000..247f87319 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts new file mode 100644 index 000000000..e34f2c104 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts new file mode 100644 index 000000000..e4d255750 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts new file mode 100644 index 000000000..270a31d8e --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index 579e21e26..1e1a70288 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -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.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++) { if (i > 1) { 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}`); } + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + await page.getByLabel('Enable signing order').check(); + await page.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts new file mode 100644 index 000000000..5a167340a --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts new file mode 100644 index 000000000..af12e7290 --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts new file mode 100644 index 000000000..f5bd07e94 --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts @@ -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(); + }); +}); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 5fb03ada5..3536e340d 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ testDir: './e2e', /* Run tests in files in parallel */ fullyParallel: false, - workers: 1, + workers: 4, maxFailures: process.env.CI ? 1 : undefined, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, diff --git a/packages/lib/client-only/hooks/use-autosave.ts b/packages/lib/client-only/hooks/use-autosave.ts new file mode 100644 index 000000000..5c9b3db62 --- /dev/null +++ b/packages/lib/client-only/hooks/use-autosave.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export const useAutoSave = (onSave: (data: T) => Promise) => { + const saveTimeoutRef = useRef(); + + 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 }; +}; diff --git a/packages/lib/server-only/recipient/get-recipients-for-template.ts b/packages/lib/server-only/recipient/get-recipients-for-template.ts index 14cafebfb..6d5c4c88f 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-template.ts @@ -1,5 +1,7 @@ import { prisma } from '@documenso/prisma'; +import { buildTeamWhereQuery } from '../../utils/teams'; + export interface GetRecipientsForTemplateOptions { templateId: number; userId: number; @@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({ const recipients = await prisma.recipient.findMany({ where: { templateId, - template: teamId - ? { - team: { - id: teamId, - members: { - some: { - userId, - }, - }, - }, - } - : { - userId, - teamId: null, - }, + template: { + team: buildTeamWhereQuery({ + teamId, + userId, + }), + }, }, orderBy: { id: 'asc', diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 137516804..820696e0e 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -21,6 +21,7 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { prop, sortBy } from 'remeda'; 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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { @@ -83,6 +84,7 @@ export type AddFieldsFormProps = { recipients: Recipient[]; fields: Field[]; onSubmit: (_data: TAddFieldsFormSchema) => void; + onAutoSave: (_data: TAddFieldsFormSchema) => Promise; canGoBack?: boolean; isDocumentPdfLoaded: boolean; teamId: number; @@ -94,6 +96,7 @@ export const AddFieldsFormPartial = ({ recipients, fields, onSubmit, + onAutoSave, canGoBack = false, isDocumentPdfLoaded, 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 ( <> {showAdvancedSettings && currentField ? ( @@ -603,7 +620,14 @@ export const AddFieldsFormPartial = ({ fields={localFields} onAdvancedSettings={handleAdvancedSettings} 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} passive={isFieldWithinBounds && !!selectedField} onFocus={() => setLastActiveField(field)} - onBlur={() => setLastActiveField(null)} + onBlur={() => { + setLastActiveField(null); + void handleAutoSave(); + }} onMouseEnter={() => setLastActiveField(field)} onMouseLeave={() => setLastActiveField(null)} onResize={(options) => onFieldResize(options, index)} onMove={(options) => onFieldMove(options, index)} - onRemove={() => remove(index)} - onDuplicate={() => onFieldCopy(null, { duplicate: true })} - onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} + onRemove={() => { + remove(index); + void handleAutoSave(); + }} + onDuplicate={() => { + onFieldCopy(null, { duplicate: true }); + void handleAutoSave(); + }} + onDuplicateAllPages={() => { + onFieldCopy(null, { duplicateAll: true }); + void handleAutoSave(); + }} onAdvancedSettings={() => { setCurrentField(field); handleAdvancedSettings(); diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 3c06f9d1c..3d1789e31 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -14,6 +14,7 @@ import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; 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 { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document'; @@ -79,6 +80,7 @@ export type AddSettingsFormProps = { document: TDocument; currentTeamMemberRole?: TeamMemberRole; onSubmit: (_data: TAddSettingsFormSchema) => void; + onAutoSave: (_data: TAddSettingsFormSchema) => Promise; }; export const AddSettingsFormPartial = ({ @@ -89,6 +91,7 @@ export const AddSettingsFormPartial = ({ document, currentTeamMemberRole, onSubmit, + onAutoSave, }: AddSettingsFormProps) => { const { t } = useLingui(); @@ -161,6 +164,28 @@ export const AddSettingsFormPartial = ({ 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 ( <> @@ -227,9 +253,13 @@ export const AddSettingsFormPartial = ({ + @@ -372,7 +413,10 @@ export const AddSettingsFormPartial = ({ value: option.value, }))} selectedValues={field.value} - onChange={field.onChange} + onChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} className="bg-background w-full" emptySelectionPlaceholder="Select signature types" /> @@ -394,8 +438,12 @@ export const AddSettingsFormPartial = ({ + diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 6b280f90f..a57c87167 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form'; import { prop, sortBy } from 'remeda'; 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 { useSession } from '@documenso/lib/client-only/providers/session'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; @@ -55,6 +56,7 @@ export type AddSignersFormProps = { signingOrder?: DocumentSigningOrder | null; allowDictateNextSigner?: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; + onAutoSave: (_data: TAddSignersFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; @@ -65,6 +67,7 @@ export const AddSignersFormPartial = ({ signingOrder, allowDictateNextSigner, onSubmit, + onAutoSave, isDocumentPdfLoaded, }: AddSignersFormProps) => { const { _ } = useLingui(); @@ -166,6 +169,29 @@ export const AddSignersFormPartial = ({ 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 isUserAlreadyARecipient = watchedSigners.some( (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); if (formStateIndex !== -1) { removeSigner(formStateIndex); + 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 = () => { if (emptySignerIndex !== -1) { - setValue(`signers.${emptySignerIndex}.name`, user?.name ?? ''); - setValue(`signers.${emptySignerIndex}.email`, user?.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, + setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', { + shouldValidate: true, + shouldDirty: true, }); + 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, })); - form.setValue('signers', updatedSigners); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); const lastSigner = updatedSigners[updatedSigners.length - 1]; if (lastSigner.role === RecipientRole.ASSISTANT) { @@ -276,8 +328,10 @@ export const AddSignersFormPartial = ({ } await form.trigger('signers'); + + void handleAutoSave(); }, - [form, canRecipientBeModified, watchedSigners, toast], + [form, canRecipientBeModified, watchedSigners, handleAutoSave, toast], ); const handleRoleChange = useCallback( @@ -287,7 +341,10 @@ export const AddSignersFormPartial = ({ // Handle parallel to sequential conversion for assistants if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { - form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); + form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { + shouldValidate: true, + shouldDirty: true, + }); toast({ title: _(msg`Signing order is enabled.`), 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, })); - form.setValue('signers', updatedSigners); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { toast({ @@ -341,7 +401,10 @@ export const AddSignersFormPartial = ({ 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) { toast({ @@ -364,9 +427,20 @@ export const AddSignersFormPartial = ({ role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, })); - form.setValue('signers', updatedSigners); - form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); - form.setValue('allowDictateNextSigner', false); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue('allowDictateNextSigner', false, { + shouldValidate: true, + shouldDirty: true, + }); + + void handleAutoSave(); }, [form]); return ( @@ -408,19 +482,39 @@ export const AddSignersFormPartial = ({ // If sequential signing is turned off, disable dictate next signer 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} /> - - Enable signing order - +
+ + Enable signing order + + + + + + + + + +

+ Add 2 or more signers to enable signing order. +

+
+
+
)} /> @@ -435,12 +529,15 @@ export const AddSignersFormPartial = ({ {...field} id="allowDictateNextSigner" checked={value} - onCheckedChange={field.onChange} + onCheckedChange={(checked) => { + field.onChange(checked); + void handleAutoSave(); + }} disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential} /> -
+
{ field.onChange(e); handleSigningOrderChange(index, e.target.value); + void handleAutoSave(); }} onBlur={(e) => { field.onBlur(); handleSigningOrderChange(index, e.target.value); + void handleAutoSave(); }} disabled={ snapshot.isDragging || @@ -588,7 +688,9 @@ export const AddSignersFormPartial = ({ isSubmitting || !canRecipientBeModified(signer.nativeId) } + data-testid="signer-email-input" onKeyDown={onKeyDown} + onBlur={handleAutoSave} /> @@ -626,6 +728,7 @@ export const AddSignersFormPartial = ({ !canRecipientBeModified(signer.nativeId) } onKeyDown={onKeyDown} + onBlur={handleAutoSave} /> @@ -668,6 +771,7 @@ export const AddSignersFormPartial = ({
( + onValueChange={(value) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole) - } + handleRoleChange(index, value as RecipientRole); + void handleAutoSave(); + }} disabled={ snapshot.isDragging || isSubmitting || @@ -706,6 +811,7 @@ export const AddSignersFormPartial = ({ 'mb-6': form.formState.errors.signers?.[index], }, )} + data-testid="remove-signer-button" disabled={ snapshot.isDragging || isSubmitting || diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 6553597f2..82f6f11d5 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; @@ -8,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { InfoIcon } from 'lucide-react'; 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 { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TDocument } from '@documenso/lib/types/document'; @@ -60,6 +63,7 @@ export type AddSubjectFormProps = { fields: Field[]; document: TDocument; onSubmit: (_data: TAddSubjectFormSchema) => void; + onAutoSave: (_data: TAddSubjectFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; @@ -69,6 +73,7 @@ export const AddSubjectFormPartial = ({ fields: fields, document, onSubmit, + onAutoSave, isDocumentPdfLoaded, }: AddSubjectFormProps) => { const { _ } = useLingui(); @@ -95,6 +100,8 @@ export const AddSubjectFormPartial = ({ handleSubmit, setValue, watch, + trigger, + getValues, formState: { isSubmitting }, } = form; @@ -129,6 +136,35 @@ export const AddSubjectFormPartial = ({ const onFormSubmit = handleSubmit(onSubmit); 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 ( <> Email Sender - @@ -592,6 +663,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ signers[index].email === user?.email || isSignerDirectRecipient(signer) } + onBlur={handleAutoSave} + data-testid="placeholder-recipient-name-input" /> @@ -633,10 +706,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ + onValueChange={(value) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole) - } + handleRoleChange(index, value as RecipientRole); + }} disabled={isSubmitting} 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" disabled={isSubmitting || signers.length === 1} onClick={() => onRemoveSigner(index)} + data-testid="remove-placeholder-recipient-button" > diff --git a/packages/ui/primitives/template-flow/add-template-settings.tsx b/packages/ui/primitives/template-flow/add-template-settings.tsx index d880b8edb..374e31a69 100644 --- a/packages/ui/primitives/template-flow/add-template-settings.tsx +++ b/packages/ui/primitives/template-flow/add-template-settings.tsx @@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; 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 { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { @@ -83,6 +84,7 @@ export type AddTemplateSettingsFormProps = { template: TTemplate; currentTeamMemberRole?: TeamMemberRole; onSubmit: (_data: TAddTemplateSettingsFormSchema) => void; + onAutoSave: (_data: TAddTemplateSettingsFormSchema) => Promise; }; export const AddTemplateSettingsFormPartial = ({ @@ -93,6 +95,7 @@ export const AddTemplateSettingsFormPartial = ({ template, currentTeamMemberRole, onSubmit, + onAutoSave, }: AddTemplateSettingsFormProps) => { const { t, i18n } = useLingui(); @@ -160,6 +163,28 @@ export const AddTemplateSettingsFormPartial = ({ } }, [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 ( <> - + @@ -219,7 +244,13 @@ export const AddTemplateSettingsFormPartial = ({ - { + field.onChange(value); + void handleAutoSave(); + }} + > @@ -250,9 +281,13 @@ export const AddTemplateSettingsFormPartial = ({ { + field.onChange(value); + void handleAutoSave(); + }} value={field.value} disabled={field.disabled} - onValueChange={field.onChange} /> @@ -275,7 +310,10 @@ export const AddTemplateSettingsFormPartial = ({ canUpdateVisibility={canUpdateVisibility} currentTeamMemberRole={currentTeamMemberRole} {...field} - onValueChange={field.onChange} + onValueChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} /> @@ -334,7 +372,13 @@ export const AddTemplateSettingsFormPartial = ({ - { + field.onChange(value); + void handleAutoSave(); + }} + > @@ -371,7 +415,10 @@ export const AddTemplateSettingsFormPartial = ({ value: option.value, }))} selectedValues={field.value} - onChange={field.onChange} + onChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} className="bg-background w-full" emptySelectionPlaceholder="Select signature types" /> @@ -395,9 +442,13 @@ export const AddTemplateSettingsFormPartial = ({ { + field.onChange(value); + void handleAutoSave(); + }} value={field.value} disabled={field.disabled} - onValueChange={field.onChange} /> @@ -488,7 +539,7 @@ export const AddTemplateSettingsFormPartial = ({ - + @@ -515,7 +566,11 @@ export const AddTemplateSettingsFormPartial = ({ -