diff --git a/docs/plugins/form-builder.mdx b/docs/plugins/form-builder.mdx index 7c36c04c1f..acc7c58372 100644 --- a/docs/plugins/form-builder.mdx +++ b/docs/plugins/form-builder.mdx @@ -136,6 +136,18 @@ const beforeEmail: BeforeEmail = (emailsToSend, beforeChangePara } ``` +### `defaultToEmail` + +Provide a fallback for the email address to send form submissions to. If the email in form configuration does not have a to email set, this email address will be used. If this is not provided then it falls back to the `defaultFromAddress` in your [email configuration](https://payloadcms.com/docs/beta/email/overview). + +```ts +// payload.config.ts +formBuilder({ + // ... + defaultToEmail: 'test@example.com', +}) +``` + ### `formOverrides` Override anything on the `forms` collection by sending a [Payload Collection Config](https://payloadcms.com/docs/configuration/collections) to the `formOverrides` property. @@ -396,7 +408,19 @@ formBuilder({ ## Email -This plugin relies on the [email configuration](https://payloadcms.com/docs/email/overview) defined in your `payload.init()`. It will read from your config and attempt to send your emails using the credentials provided. +This plugin relies on the [email configuration](https://payloadcms.com/docs/beta/email/overview) defined in your payload configuration. It will read from your config and attempt to send your emails using the credentials provided. + +### Email formatting + +The email contents supports rich text which will be serialised to HTML on the server before being sent. By default it reads the global configuration of your rich text editor. + +The email subject and body supports inserting dynamic fields from the form submission data using the `{{field_name}}` syntax. For example, if you have a field called `name` in your form, you can include this in the email body like so: + +```html +Thank you for your submission, {{name}}! +``` + +You can also use `{{*}}` as a wildcard to output all the data in a key:value format and `{{*:table}}` to output all the data in a table format. ## TypeScript diff --git a/packages/plugin-form-builder/src/collections/FormSubmissions/hooks/sendEmail.ts b/packages/plugin-form-builder/src/collections/FormSubmissions/hooks/sendEmail.ts index 53f9767e2f..239aeeb7f6 100644 --- a/packages/plugin-form-builder/src/collections/FormSubmissions/hooks/sendEmail.ts +++ b/packages/plugin-form-builder/src/collections/FormSubmissions/hooks/sendEmail.ts @@ -22,7 +22,7 @@ export const sendEmail = async ( const { form: formID, submissionData } = data || {} - const { beforeEmail, formOverrides } = formConfig || {} + const { beforeEmail, defaultToEmail, formOverrides } = formConfig || {} try { const form = await payload.findByID({ @@ -41,12 +41,14 @@ export const sendEmail = async ( bcc: emailBCC, cc: emailCC, emailFrom, - emailTo, + emailTo: emailToFromConfig, message, replyTo: emailReplyTo, subject, } = email + const emailTo = emailToFromConfig || defaultToEmail || payload.email.defaultFromAddress + const to = replaceDoubleCurlys(emailTo, submissionData) const cc = emailCC ? replaceDoubleCurlys(emailCC, submissionData) : '' const bcc = emailBCC ? replaceDoubleCurlys(emailBCC, submissionData) : '' diff --git a/packages/plugin-form-builder/src/collections/Forms/index.ts b/packages/plugin-form-builder/src/collections/Forms/index.ts index 1229fd64c7..63117bf9c1 100644 --- a/packages/plugin-form-builder/src/collections/Forms/index.ts +++ b/packages/plugin-form-builder/src/collections/Forms/index.ts @@ -140,7 +140,7 @@ export const generateFormCollection = (formConfig: FormBuilderPluginConfig): Col type: 'array', admin: { description: - "Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}.", + "Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email.", }, fields: [ { diff --git a/packages/plugin-form-builder/src/types.ts b/packages/plugin-form-builder/src/types.ts index 9e7037a7fc..7a9ec4951f 100644 --- a/packages/plugin-form-builder/src/types.ts +++ b/packages/plugin-form-builder/src/types.ts @@ -53,6 +53,11 @@ export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] export type FormBuilderPluginConfig = { beforeEmail?: BeforeEmail + /** + * Set a default email address to send form submissions to if no email is provided in the form configuration + * Falls back to the defaultFromAddress in the email configuration + */ + defaultToEmail?: string fields?: FieldsConfig formOverrides?: { fields?: FieldsOverride } & Partial> formSubmissionOverrides?: { fields?: FieldsOverride } & Partial> diff --git a/packages/plugin-form-builder/src/utilities/keyValuePairToHtmlTable.ts b/packages/plugin-form-builder/src/utilities/keyValuePairToHtmlTable.ts new file mode 100644 index 0000000000..940df29c5c --- /dev/null +++ b/packages/plugin-form-builder/src/utilities/keyValuePairToHtmlTable.ts @@ -0,0 +1,10 @@ +export function keyValuePairToHtmlTable(obj: { [key: string]: string }): string { + let htmlTable = '' + + for (const [key, value] of Object.entries(obj)) { + htmlTable += `` + } + + htmlTable += '
${key}${value}
' + return htmlTable +} diff --git a/packages/plugin-form-builder/src/utilities/replaceDoubleCurlys.ts b/packages/plugin-form-builder/src/utilities/replaceDoubleCurlys.ts index e053d8d0a7..1ecef76ab6 100644 --- a/packages/plugin-form-builder/src/utilities/replaceDoubleCurlys.ts +++ b/packages/plugin-form-builder/src/utilities/replaceDoubleCurlys.ts @@ -1,3 +1,5 @@ +import { keyValuePairToHtmlTable } from './keyValuePairToHtmlTable.js' + interface EmailVariable { field: string value: string @@ -8,11 +10,27 @@ type EmailVariables = EmailVariable[] export const replaceDoubleCurlys = (str: string, variables?: EmailVariables): string => { const regex = /\{\{(.+?)\}\}/g if (str && variables) { - return str.replace(regex, (_, variable) => { - const foundVariable = variables.find(({ field: fieldName }) => variable === fieldName) - if (foundVariable) { - return foundVariable.value + return str.replace(regex, (_, variable: string) => { + if (variable.includes('*')) { + if (variable === '*') { + return variables.map(({ field, value }) => `${field} : ${value}`).join('
') + } else if (variable === '*:table') { + return keyValuePairToHtmlTable( + variables.reduce((acc, { field, value }) => { + acc[field] = value + return acc + }, {}), + ) + } + } else { + const foundVariable = variables.find(({ field: fieldName }) => { + return variable === fieldName + }) + if (foundVariable) { + return foundVariable.value + } } + return variable }) } diff --git a/packages/plugin-form-builder/src/utilities/slate/serializeSlate.ts b/packages/plugin-form-builder/src/utilities/slate/serializeSlate.ts index 493c7ba3b0..674a030b57 100644 --- a/packages/plugin-form-builder/src/utilities/slate/serializeSlate.ts +++ b/packages/plugin-form-builder/src/utilities/slate/serializeSlate.ts @@ -20,7 +20,9 @@ export const serializeSlate = (children?: Node[], submissionData?: any): string children ?.map((node: Node) => { if (isTextNode(node)) { - let text = `${escapeHTML(replaceDoubleCurlys(node.text, submissionData))}` + let text = node.text.includes('{{*') + ? replaceDoubleCurlys(node.text, submissionData) + : `${escapeHTML(replaceDoubleCurlys(node.text, submissionData))}` if (node.bold) { text = ` diff --git a/test/plugin-form-builder/config.ts b/test/plugin-form-builder/config.ts index acfc65594b..cfa28c217e 100644 --- a/test/plugin-form-builder/config.ts +++ b/test/plugin-form-builder/config.ts @@ -5,8 +5,9 @@ const dirname = path.dirname(filename) import type { BeforeEmail } from '@payloadcms/plugin-form-builder/types' import type { Block } from 'payload' +//import { nodemailerAdapter } from '@payloadcms/email-nodemailer' import { formBuilderPlugin, fields as formFields } from '@payloadcms/plugin-form-builder' -import { slateEditor } from '@payloadcms/richtext-slate' +import { lexicalEditor } from '@payloadcms/richtext-lexical' import type { FormSubmission } from './payload-types.js' @@ -41,7 +42,7 @@ export default buildConfigWithDefaults({ }, }, collections: [Pages, Users], - editor: slateEditor({}), + editor: lexicalEditor({}), localization: { defaultLocale: 'en', fallback: true, @@ -58,9 +59,11 @@ export default buildConfigWithDefaults({ await seed(payload) }, + //email: nodemailerAdapter(), plugins: [ formBuilderPlugin({ // handlePayment: handleFormPayments, + //defaultToEmail: 'devs@payloadcms.com', fields: { colorField, payment: true, diff --git a/test/plugin-form-builder/int.spec.ts b/test/plugin-form-builder/int.spec.ts index df3aa3df6e..3db9639244 100644 --- a/test/plugin-form-builder/int.spec.ts +++ b/test/plugin-form-builder/int.spec.ts @@ -9,7 +9,7 @@ import type { Form } from './payload-types.js' import { serializeLexical } from '../../packages/plugin-form-builder/src/utilities/lexical/serializeLexical.js' import { serializeSlate } from '../../packages/plugin-form-builder/src/utilities/slate/serializeSlate.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' -import { formSubmissionsSlug, formsSlug } from './shared.js' +import { formsSlug, formSubmissionsSlug } from './shared.js' let payload: Payload let form: Form @@ -22,12 +22,38 @@ describe('@payloadcms/plugin-form-builder', () => { ;({ payload } = await initPayloadInt(dirname)) const formConfig: Omit = { - confirmationMessage: [ - { - type: 'text', - text: 'Confirmed.', + confirmationType: 'message', + confirmationMessage: { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Confirmed.', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, }, - ], + }, fields: [ { name: 'name', @@ -64,12 +90,38 @@ describe('@payloadcms/plugin-form-builder', () => { describe('form building', () => { it('can create a simple form', async () => { const formConfig: Omit = { - confirmationMessage: [ - { - type: 'text', - text: 'Confirmed.', + confirmationType: 'message', + confirmationMessage: { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Confirmed.', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, }, - ], + }, fields: [ { name: 'name', @@ -91,12 +143,38 @@ describe('@payloadcms/plugin-form-builder', () => { it('can use form overrides', async () => { const formConfig: Omit = { - confirmationMessage: [ - { - type: 'text', - text: 'Confirmed.', + confirmationType: 'message', + confirmationMessage: { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Confirmed.', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, }, - ], + }, custom: 'custom', title: 'Test Form', } @@ -152,65 +230,108 @@ describe('@payloadcms/plugin-form-builder', () => { await expect(req).rejects.toThrow(ValidationError) }) - it('replaces curly braces with data when using slate serializer', () => { - const mockName = 'Test Submission' - const mockEmail = 'dev@payloadcms.com' + describe('replaces curly braces', () => { + describe('slate serializer', () => { + it('specific field names', () => { + const mockName = 'Test Submission' + const mockEmail = 'dev@payloadcms.com' - const serializedEmail = serializeSlate( - [ - { text: 'Welcome {{name}}. Here is a dynamic ' }, - { - type: 'link', - children: [ + const serializedEmail = serializeSlate( + [ + { text: 'Welcome {{name}}. Here is a dynamic ' }, { - text: 'link', - }, - ], - url: 'www.test.com?email={{email}}', - }, - ], - [ - { field: 'name', value: mockName }, - { field: 'email', value: mockEmail }, - ], - ) - - expect(serializedEmail).toContain(mockName) - expect(serializedEmail).toContain(mockEmail) - }) - - it('replaces curly braces with data when using lexical serializer', async () => { - const mockName = 'Test Submission' - const mockEmail = 'dev@payloadcms.com' - - const serializedEmail = await serializeLexical( - { - root: { - type: 'root', - children: [ - { - type: 'paragraph', + type: 'link', children: [ { - type: 'text', - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: 'Name: {{name}}', - version: 1, + text: 'link', }, + ], + url: 'www.test.com?email={{email}}', + }, + ], + [ + { field: 'name', value: mockName }, + { field: 'email', value: mockEmail }, + ], + ) + + expect(serializedEmail).toContain(mockName) + expect(serializedEmail).toContain(mockEmail) + }) + + it('wildcard "{{*}}"', () => { + const mockName = 'Test Submission' + const mockEmail = 'dev@payloadcms.com' + + const serializedEmail = serializeSlate( + [{ text: '{{*}}' }], + [ + { field: 'name', value: mockName }, + { field: 'email', value: mockEmail }, + ], + ) + + expect(serializedEmail).toContain(`name : ${mockName}`) + expect(serializedEmail).toContain(`email : ${mockEmail}`) + }) + + it('wildcard with table formatting "{{*:table}}"', () => { + const mockName = 'Test Submission' + const mockEmail = 'dev@payloadcms.com' + + const serializedEmail = serializeSlate( + [{ text: '{{*:table}}' }], + [ + { field: 'name', value: mockName }, + { field: 'email', value: mockEmail }, + ], + ) + + expect(serializedEmail).toContain(``) + expect(serializedEmail).toContain(``) + expect(serializedEmail).toContain(``) + }) + }) + + describe('lexical serializer', () => { + it('specific field names', async () => { + const mockName = 'Test Submission' + const mockEmail = 'dev@payloadcms.com' + + const serializedEmail = await serializeLexical( + { + root: { + type: 'root', + children: [ { - type: 'linebreak', - version: 1, - }, - { - type: 'text', - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: 'Email: {{email}}', + type: 'paragraph', + children: [ + { + type: 'text', + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Name: {{name}}', + version: 1, + }, + { + type: 'linebreak', + version: 1, + }, + { + type: 'text', + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Email: {{email}}', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, version: 1, }, ], @@ -219,21 +340,106 @@ describe('@payloadcms/plugin-form-builder', () => { indent: 0, version: 1, }, + }, + [ + { field: 'name', value: mockName }, + { field: 'email', value: mockEmail }, ], - direction: 'ltr', - format: '', - indent: 0, - version: 1, - }, - }, - [ - { field: 'name', value: mockName }, - { field: 'email', value: mockEmail }, - ], - ) + ) - expect(serializedEmail).toContain(`Name: ${mockName}`) - expect(serializedEmail).toContain(`Email: ${mockEmail}`) + expect(serializedEmail).toContain(`Name: ${mockName}`) + expect(serializedEmail).toContain(`Email: ${mockEmail}`) + }) + + it('wildcard "{{*}}"', async () => { + const mockName = 'Test Submission' + const mockEmail = 'dev@payloadcms.com' + + const serializedEmail = await serializeLexical( + { + root: { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: '{{*}}', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + }, + [ + { field: 'name', value: mockName }, + { field: 'email', value: mockEmail }, + ], + ) + + expect(serializedEmail).toContain(`name : ${mockName}`) + expect(serializedEmail).toContain(`email : ${mockEmail}`) + }) + + it('wildcard with table formatting "{{*:table}}"', async () => { + const mockName = 'Test Submission' + const mockEmail = 'dev@payloadcms.com' + + const serializedEmail = await serializeLexical( + { + root: { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: '{{*:table}}', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + }, + [ + { field: 'name', value: mockName }, + { field: 'email', value: mockEmail }, + ], + ) + + expect(serializedEmail).toContain(`
name${mockName}
email${mockEmail}
`) + expect(serializedEmail).toContain(``) + expect(serializedEmail).toContain(``) + }) + }) }) }) }) diff --git a/test/plugin-form-builder/payload-types.ts b/test/plugin-form-builder/payload-types.ts index 1fc0e4f108..792f06f27a 100644 --- a/test/plugin-form-builder/payload-types.ts +++ b/test/plugin-form-builder/payload-types.ts @@ -95,11 +95,21 @@ export interface Form { blockType: 'email'; } | { - message?: - | { + message?: { + root: { + type: string; + children: { + type: string; + version: number; [k: string]: unknown; - }[] - | null; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; id?: string | null; blockName?: string | null; blockType: 'message'; @@ -191,11 +201,21 @@ export interface Form { | null; submitButtonLabel?: string | null; confirmationType?: ('message' | 'redirect') | null; - confirmationMessage?: - | { + confirmationMessage?: { + root: { + type: string; + children: { + type: string; + version: number; [k: string]: unknown; - }[] - | null; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; redirect?: { type?: ('reference' | 'custom') | null; reference?: { @@ -212,11 +232,21 @@ export interface Form { replyTo?: string | null; emailFrom?: string | null; subject: string; - message?: - | { + message?: { + root: { + type: string; + children: { + type: string; + version: number; [k: string]: unknown; - }[] - | null; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; id?: string | null; }[] | null; diff --git a/test/plugin-form-builder/seed/index.ts b/test/plugin-form-builder/seed/index.ts index 11e1cf2cc6..a508889208 100644 --- a/test/plugin-form-builder/seed/index.ts +++ b/test/plugin-form-builder/seed/index.ts @@ -1,6 +1,6 @@ import type { Payload, PayloadRequest } from 'payload' -import { formSubmissionsSlug, formsSlug, pagesSlug } from '../shared.js' +import { formsSlug, formSubmissionsSlug, pagesSlug } from '../shared.js' export const seed = async (payload: Payload): Promise => { payload.logger.info('Seeding data...') @@ -28,12 +28,38 @@ export const seed = async (payload: Payload): Promise => { const { id: formID } = await payload.create({ collection: formsSlug, data: { - confirmationMessage: [ - { - type: 'paragraph', - text: 'Confirmed', + confirmationType: 'message', + confirmationMessage: { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Confirmed', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, }, - ], + }, fields: [ { name: 'name',
name${mockName}
email${mockEmail}