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:
@@ -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
|
||||
|
||||
|
||||
@@ -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) : ''
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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'>>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user