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`
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

View File

@@ -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) : ''

View File

@@ -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: [
{

View File

@@ -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<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 {
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)
return str.replace(regex, (_, variable: string) => {
if (variable.includes('*')) {
if (variable === '*') {
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
})
}

View File

@@ -20,7 +20,9 @@ export const serializeSlate = (children?: Node[], submissionData?: any): string
children
?.map((node: 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) {
text = `

View File

@@ -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,

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 { 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<Form, 'createdAt' | 'id' | 'updatedAt'> = {
confirmationMessage: [
confirmationType: 'message',
confirmationMessage: {
root: {
children: [
{
type: 'text',
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<Form, 'createdAt' | 'id' | 'updatedAt'> = {
confirmationMessage: [
confirmationType: 'message',
confirmationMessage: {
root: {
children: [
{
type: 'text',
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<Form, 'createdAt' | 'id' | 'updatedAt'> = {
confirmationMessage: [
confirmationType: 'message',
confirmationMessage: {
root: {
children: [
{
type: 'text',
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,7 +230,9 @@ describe('@payloadcms/plugin-form-builder', () => {
await expect(req).rejects.toThrow(ValidationError)
})
it('replaces curly braces with data when using slate serializer', () => {
describe('replaces curly braces', () => {
describe('slate serializer', () => {
it('specific field names', () => {
const mockName = 'Test Submission'
const mockEmail = 'dev@payloadcms.com'
@@ -179,7 +259,42 @@ describe('@payloadcms/plugin-form-builder', () => {
expect(serializedEmail).toContain(mockEmail)
})
it('replaces curly braces with data when using lexical serializer', async () => {
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'
@@ -235,5 +350,96 @@ describe('@payloadcms/plugin-form-builder', () => {
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(`<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';
}
| {
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;

View File

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