feat(plugin-form-builder): new wildcards {{*}} and {{*:table}} are now supported in email bodies and emails fall back to a global configuration value in addition to base email configuration (#8271)

Email bodies in the plugin form builder now support wildcards `{{*}}`
and `{{*:table}}` to export all the form submission data in key:value
pairs with the latter formatted as a table.

Emails also fallback to a global plugin configuration item and then to
the `defaultFromAddress` address in email transport config.
This commit is contained in:
Paul
2024-09-17 18:28:52 -06:00
committed by GitHub
parent dd96f9a058
commit 9821aeb67a
11 changed files with 437 additions and 111 deletions

View File

@@ -136,6 +136,18 @@ const beforeEmail: BeforeEmail<FormSubmission> = (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` ### `formOverrides`
Override anything on the `forms` collection by sending a [Payload Collection Config](https://payloadcms.com/docs/configuration/collections) to the `formOverrides` property. 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 ## 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 ## TypeScript

View File

@@ -22,7 +22,7 @@ export const sendEmail = async (
const { form: formID, submissionData } = data || {} const { form: formID, submissionData } = data || {}
const { beforeEmail, formOverrides } = formConfig || {} const { beforeEmail, defaultToEmail, formOverrides } = formConfig || {}
try { try {
const form = await payload.findByID({ const form = await payload.findByID({
@@ -41,12 +41,14 @@ export const sendEmail = async (
bcc: emailBCC, bcc: emailBCC,
cc: emailCC, cc: emailCC,
emailFrom, emailFrom,
emailTo, emailTo: emailToFromConfig,
message, message,
replyTo: emailReplyTo, replyTo: emailReplyTo,
subject, subject,
} = email } = email
const emailTo = emailToFromConfig || defaultToEmail || payload.email.defaultFromAddress
const to = replaceDoubleCurlys(emailTo, submissionData) const to = replaceDoubleCurlys(emailTo, submissionData)
const cc = emailCC ? replaceDoubleCurlys(emailCC, submissionData) : '' const cc = emailCC ? replaceDoubleCurlys(emailCC, submissionData) : ''
const bcc = emailBCC ? replaceDoubleCurlys(emailBCC, submissionData) : '' const bcc = emailBCC ? replaceDoubleCurlys(emailBCC, submissionData) : ''

View File

@@ -140,7 +140,7 @@ export const generateFormCollection = (formConfig: FormBuilderPluginConfig): Col
type: 'array', type: 'array',
admin: { admin: {
description: 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: [ fields: [
{ {

View File

@@ -53,6 +53,11 @@ export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export type FormBuilderPluginConfig = { export type FormBuilderPluginConfig = {
beforeEmail?: BeforeEmail 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 fields?: FieldsConfig
formOverrides?: { fields?: FieldsOverride } & Partial<Omit<CollectionConfig, 'fields'>> formOverrides?: { fields?: FieldsOverride } & Partial<Omit<CollectionConfig, 'fields'>>
formSubmissionOverrides?: { fields?: FieldsOverride } & Partial<Omit<CollectionConfig, 'fields'>> formSubmissionOverrides?: { fields?: FieldsOverride } & Partial<Omit<CollectionConfig, 'fields'>>

View File

@@ -0,0 +1,10 @@
export function keyValuePairToHtmlTable(obj: { [key: string]: string }): string {
let htmlTable = '<table>'
for (const [key, value] of Object.entries(obj)) {
htmlTable += `<tr><td>${key}</td><td>${value}</td></tr>`
}
htmlTable += '</table>'
return htmlTable
}

View File

@@ -1,3 +1,5 @@
import { keyValuePairToHtmlTable } from './keyValuePairToHtmlTable.js'
interface EmailVariable { interface EmailVariable {
field: string field: string
value: string value: string
@@ -8,11 +10,27 @@ type EmailVariables = EmailVariable[]
export const replaceDoubleCurlys = (str: string, variables?: EmailVariables): string => { export const replaceDoubleCurlys = (str: string, variables?: EmailVariables): string => {
const regex = /\{\{(.+?)\}\}/g const regex = /\{\{(.+?)\}\}/g
if (str && variables) { if (str && variables) {
return str.replace(regex, (_, variable) => { return str.replace(regex, (_, variable: string) => {
const foundVariable = variables.find(({ field: fieldName }) => variable === fieldName) if (variable.includes('*')) {
if (foundVariable) { if (variable === '*') {
return foundVariable.value return variables.map(({ field, value }) => `${field} : ${value}`).join(' <br /> ')
} 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 return variable
}) })
} }

View File

@@ -20,7 +20,9 @@ export const serializeSlate = (children?: Node[], submissionData?: any): string
children children
?.map((node: Node) => { ?.map((node: Node) => {
if (isTextNode(node)) { if (isTextNode(node)) {
let text = `<span>${escapeHTML(replaceDoubleCurlys(node.text, submissionData))}</span>` let text = node.text.includes('{{*')
? replaceDoubleCurlys(node.text, submissionData)
: `<span>${escapeHTML(replaceDoubleCurlys(node.text, submissionData))}</span>`
if (node.bold) { if (node.bold) {
text = ` text = `

View File

@@ -5,8 +5,9 @@ const dirname = path.dirname(filename)
import type { BeforeEmail } from '@payloadcms/plugin-form-builder/types' import type { BeforeEmail } from '@payloadcms/plugin-form-builder/types'
import type { Block } from 'payload' import type { Block } from 'payload'
//import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import { formBuilderPlugin, fields as formFields } from '@payloadcms/plugin-form-builder' 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' import type { FormSubmission } from './payload-types.js'
@@ -41,7 +42,7 @@ export default buildConfigWithDefaults({
}, },
}, },
collections: [Pages, Users], collections: [Pages, Users],
editor: slateEditor({}), editor: lexicalEditor({}),
localization: { localization: {
defaultLocale: 'en', defaultLocale: 'en',
fallback: true, fallback: true,
@@ -58,9 +59,11 @@ export default buildConfigWithDefaults({
await seed(payload) await seed(payload)
}, },
//email: nodemailerAdapter(),
plugins: [ plugins: [
formBuilderPlugin({ formBuilderPlugin({
// handlePayment: handleFormPayments, // handlePayment: handleFormPayments,
//defaultToEmail: 'devs@payloadcms.com',
fields: { fields: {
colorField, colorField,
payment: true, payment: true,

View File

@@ -9,7 +9,7 @@ import type { Form } from './payload-types.js'
import { serializeLexical } from '../../packages/plugin-form-builder/src/utilities/lexical/serializeLexical.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 { serializeSlate } from '../../packages/plugin-form-builder/src/utilities/slate/serializeSlate.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js' import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { formSubmissionsSlug, formsSlug } from './shared.js' import { formsSlug, formSubmissionsSlug } from './shared.js'
let payload: Payload let payload: Payload
let form: Form let form: Form
@@ -22,12 +22,38 @@ describe('@payloadcms/plugin-form-builder', () => {
;({ payload } = await initPayloadInt(dirname)) ;({ payload } = await initPayloadInt(dirname))
const formConfig: Omit<Form, 'createdAt' | 'id' | 'updatedAt'> = { const formConfig: Omit<Form, 'createdAt' | 'id' | 'updatedAt'> = {
confirmationMessage: [ confirmationType: 'message',
{ confirmationMessage: {
type: 'text', root: {
text: 'Confirmed.', 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: [ fields: [
{ {
name: 'name', name: 'name',
@@ -64,12 +90,38 @@ describe('@payloadcms/plugin-form-builder', () => {
describe('form building', () => { describe('form building', () => {
it('can create a simple form', async () => { it('can create a simple form', async () => {
const formConfig: Omit<Form, 'createdAt' | 'id' | 'updatedAt'> = { const formConfig: Omit<Form, 'createdAt' | 'id' | 'updatedAt'> = {
confirmationMessage: [ confirmationType: 'message',
{ confirmationMessage: {
type: 'text', root: {
text: 'Confirmed.', 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: [ fields: [
{ {
name: 'name', name: 'name',
@@ -91,12 +143,38 @@ describe('@payloadcms/plugin-form-builder', () => {
it('can use form overrides', async () => { it('can use form overrides', async () => {
const formConfig: Omit<Form, 'createdAt' | 'id' | 'updatedAt'> = { const formConfig: Omit<Form, 'createdAt' | 'id' | 'updatedAt'> = {
confirmationMessage: [ confirmationType: 'message',
{ confirmationMessage: {
type: 'text', root: {
text: 'Confirmed.', 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', custom: 'custom',
title: 'Test Form', title: 'Test Form',
} }
@@ -152,65 +230,108 @@ describe('@payloadcms/plugin-form-builder', () => {
await expect(req).rejects.toThrow(ValidationError) await expect(req).rejects.toThrow(ValidationError)
}) })
it('replaces curly braces with data when using slate serializer', () => { describe('replaces curly braces', () => {
const mockName = 'Test Submission' describe('slate serializer', () => {
const mockEmail = 'dev@payloadcms.com' it('specific field names', () => {
const mockName = 'Test Submission'
const mockEmail = 'dev@payloadcms.com'
const serializedEmail = serializeSlate( const serializedEmail = serializeSlate(
[ [
{ text: 'Welcome {{name}}. Here is a dynamic ' }, { text: 'Welcome {{name}}. Here is a dynamic ' },
{
type: 'link',
children: [
{ {
text: 'link', type: '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',
children: [ children: [
{ {
type: 'text', text: 'link',
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Name: {{name}}',
version: 1,
}, },
],
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(`<table>`)
expect(serializedEmail).toContain(`<tr><td>name</td><td>${mockName}</td></tr>`)
expect(serializedEmail).toContain(`<tr><td>email</td><td>${mockEmail}</td></tr>`)
})
})
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', type: 'paragraph',
version: 1, children: [
}, {
{ type: 'text',
type: 'text', detail: 0,
detail: 0, format: 0,
format: 0, mode: 'normal',
mode: 'normal', style: '',
style: '', text: 'Name: {{name}}',
text: 'Email: {{email}}', 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, version: 1,
}, },
], ],
@@ -219,21 +340,106 @@ describe('@payloadcms/plugin-form-builder', () => {
indent: 0, indent: 0,
version: 1, 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(`Name: ${mockName}`)
expect(serializedEmail).toContain(`Email: ${mockEmail}`) 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(`<table>`)
expect(serializedEmail).toContain(`<tr><td>name</td><td>${mockName}</td></tr>`)
expect(serializedEmail).toContain(`<tr><td>email</td><td>${mockEmail}</td></tr>`)
})
})
}) })
}) })
}) })

View File

@@ -95,11 +95,21 @@ export interface Form {
blockType: 'email'; blockType: 'email';
} }
| { | {
message?: message?: {
| { root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown; [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; id?: string | null;
blockName?: string | null; blockName?: string | null;
blockType: 'message'; blockType: 'message';
@@ -191,11 +201,21 @@ export interface Form {
| null; | null;
submitButtonLabel?: string | null; submitButtonLabel?: string | null;
confirmationType?: ('message' | 'redirect') | null; confirmationType?: ('message' | 'redirect') | null;
confirmationMessage?: confirmationMessage?: {
| { root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown; [k: string]: unknown;
}[] }[];
| null; direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
redirect?: { redirect?: {
type?: ('reference' | 'custom') | null; type?: ('reference' | 'custom') | null;
reference?: { reference?: {
@@ -212,11 +232,21 @@ export interface Form {
replyTo?: string | null; replyTo?: string | null;
emailFrom?: string | null; emailFrom?: string | null;
subject: string; subject: string;
message?: message?: {
| { root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown; [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; id?: string | null;
}[] }[]
| null; | null;

View File

@@ -1,6 +1,6 @@
import type { Payload, PayloadRequest } from 'payload' 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<boolean> => { export const seed = async (payload: Payload): Promise<boolean> => {
payload.logger.info('Seeding data...') payload.logger.info('Seeding data...')
@@ -28,12 +28,38 @@ export const seed = async (payload: Payload): Promise<boolean> => {
const { id: formID } = await payload.create({ const { id: formID } = await payload.create({
collection: formsSlug, collection: formsSlug,
data: { data: {
confirmationMessage: [ confirmationType: 'message',
{ confirmationMessage: {
type: 'paragraph', root: {
text: 'Confirmed', 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: [ fields: [
{ {
name: 'name', name: 'name',