diff --git a/lang/en/messages.php b/lang/en/messages.php
index 4aa53188b7f..d5e85401b62 100644
--- a/lang/en/messages.php
+++ b/lang/en/messages.php
@@ -130,7 +130,7 @@
'forgot_password_sent' => 'If an account with that email address exists, a password reset link has been sent.',
'form_configure_blueprint_instructions' => 'Choose from existing blueprints or create a new one.',
'form_configure_email_attachments_instructions' => 'Attach files uploaded with the submission.',
- 'form_configure_email_body_instructions' => 'You can include submitted values using [Antlers](https://statamic.dev/frontend/antlers). Leave blank to list all submitted fields instead.',
+ 'form_configure_email_body_instructions' => 'Type @ to include submitted values. Leave blank to list all submitted fields instead.',
'form_configure_email_from_instructions' => 'Leave blank to fall back to the site default.',
'form_configure_email_views_instructions' => 'Leave both blank to use the default layout.',
'form_configure_email_description' => 'Be sure your email settings are configured to send and receive notifications when forms are submitted.',
diff --git a/resources/views/forms/automagic-email.antlers.html b/resources/views/forms/automagic-email.antlers.html
index 3d8ad5af299..d4b464ab39e 100644
--- a/resources/views/forms/automagic-email.antlers.html
+++ b/resources/views/forms/automagic-email.antlers.html
@@ -1,5 +1,5 @@
{{ if email_config:body }}
- {{ email_config:body | sanitize | nl2br }}
+ {{ email_config:body }}
{{ else }}
{{ fields }}
{{ display }}:
diff --git a/src/Facades/Form.php b/src/Facades/Form.php
index dd70edec0e4..62daf4743a2 100644
--- a/src/Facades/Form.php
+++ b/src/Facades/Form.php
@@ -17,6 +17,8 @@
* @method static array extraConfigFor(string $handle)
* @method static array extraConfigPositionsFor(string $handle)
* @method static self redirect(string $form, \Closure $callback)
+ * @method static self addMention(string $handle, string $label, \Closure $value)
+ * @method static array additionalMentions()
* @method static \Closure getSubmissionRedirect(Submission $submission)
* @method static ExporterRepository exporters()
*
diff --git a/src/Fieldtypes/AutocompleteEditor/Mentions.php b/src/Fieldtypes/AutocompleteEditor/Mentions.php
index cbdb51330a7..3514dcda90b 100644
--- a/src/Fieldtypes/AutocompleteEditor/Mentions.php
+++ b/src/Fieldtypes/AutocompleteEditor/Mentions.php
@@ -26,6 +26,13 @@ public static function isValidValue(string $value): bool
return (bool) preg_match('/^'.self::VALUE.'$/D', $value);
}
+ public static function handles(string $content): array
+ {
+ preg_match_all(self::RESOLVABLE, $content, $matches, PREG_UNMATCHED_AS_NULL);
+
+ return array_values(array_unique(array_filter($matches[2], fn ($handle) => $handle !== null)));
+ }
+
// Pass `$using` to run a template pass (e.g. Antlers) over the content. Neither
// order is safe without it: parsing first lets submitted values be read as
// mentions or have their backslashes eaten, and resolving first turns them
diff --git a/src/Forms/Connectors/Connector.php b/src/Forms/Connectors/Connector.php
index 40b2673b36c..68fc5780d23 100644
--- a/src/Forms/Connectors/Connector.php
+++ b/src/Forms/Connectors/Connector.php
@@ -13,6 +13,7 @@
use Statamic\Fields\Blueprint;
use Statamic\Fields\Fields;
use Statamic\Fields\Validator;
+use Statamic\Forms\SubmissionMentions;
use Statamic\Statamic;
use Statamic\Support\Arr;
use Statamic\Support\Str;
@@ -232,6 +233,11 @@ protected function processConnection(array $connection): array
return $this->blueprint()->fields()->addValues($connection)->process()->values()->all();
}
+ protected function mentionOptions(): array
+ {
+ return SubmissionMentions::options($this->form());
+ }
+
public function routes(Router $router): void
{
//
diff --git a/src/Forms/Connectors/Email.php b/src/Forms/Connectors/Email.php
index cfd982c7b9a..ebe56655c77 100644
--- a/src/Forms/Connectors/Email.php
+++ b/src/Forms/Connectors/Email.php
@@ -169,16 +169,18 @@ public function blueprint(): \Statamic\Fields\Blueprint
[
'handle' => 'subject',
'field' => [
- 'type' => 'text',
+ 'type' => 'autocomplete_editor',
'display' => __('Subject'),
+ 'markdown' => false,
+ 'options' => $this->mentionOptions(),
],
],
- // TODO: Replace with autocomplete editor once its merged (https://github.com/statamic/cms/pull/15055)
[
'handle' => 'body',
'field' => [
- 'type' => 'textarea',
+ 'type' => 'autocomplete_editor',
'display' => __('Message'),
+ 'options' => $this->mentionOptions(),
'instructions' => __('statamic::messages.form_configure_email_body_instructions'),
],
],
diff --git a/src/Forms/Email.php b/src/Forms/Email.php
index f0b5aec2b6a..154acac6de4 100644
--- a/src/Forms/Email.php
+++ b/src/Forms/Email.php
@@ -14,6 +14,8 @@
use Statamic\Facades\Form;
use Statamic\Facades\GlobalSet;
use Statamic\Facades\Parse;
+use Statamic\Fieldtypes\AutocompleteEditor;
+use Statamic\Fieldtypes\AutocompleteEditor\Mentions;
use Statamic\Forms\Fields\FormField;
use Statamic\Sites\Site;
use Statamic\Support\Arr;
@@ -30,6 +32,7 @@ class Email extends Mailable
protected $submissionData;
protected $config;
protected $site;
+ protected $mentionValues = [];
private $globalData;
public function __construct(Submission $submission, array $config, Site $site)
@@ -71,14 +74,33 @@ public function build()
$this->submissionData = $this->submission->toAugmentedArray();
$this->config = $this->parseConfig($this->config);
+ $subject = __((string) ($this->config['subject'] ?? ''));
+ $body = (string) ($this->config['body'] ?? '');
+
+ $this->mentionValues = SubmissionMentions::values(
+ $this->submission,
+ array_values(array_unique([
+ ...SubmissionMentions::handlesIn($subject),
+ ...SubmissionMentions::handlesIn($body, markdown: true),
+ ])),
+ $this->submissionData
+ );
+
$this
- ->subject(isset($this->config['subject']) ? __($this->config['subject']) : __('Form Submission'))
+ ->subject($this->renderSubject($subject))
->addAddresses()
->addViews()
->addAttachments()
->addData();
}
+ protected function renderSubject(string $subject): string
+ {
+ $subject = Mentions::replace($subject, $this->mentionValues, using: fn ($text) => $this->parseConfigValue($text));
+
+ return trim(preg_replace('/\s+/', ' ', $subject)) ?: __('Form Submission');
+ }
+
protected function addAddresses()
{
$this->to($this->addresses(Arr::get($this->config, 'to')));
@@ -192,9 +214,15 @@ protected function addData()
? Blueprint::makeFromTabs($configFields)->fields()->addValues($form->data()->all())->augment()->values()->all()
: [];
+ $emailConfig = $this->config;
+
+ if (filled($body = $emailConfig->get('body'))) {
+ $emailConfig = $emailConfig->merge(['body' => AutocompleteEditor::renderHtml($body, $this->mentionValues)]);
+ }
+
$data = array_merge($augmented, $this->getGlobalsData(), [
'form_config' => $formConfig,
- 'email_config' => $this->config,
+ 'email_config' => $emailConfig,
'config' => Cascade::config(),
'fields' => $fields,
'sections' => $sections->all(),
@@ -313,9 +341,11 @@ protected function parseConfig(array $config): Collection
{
return collect($config)
->except('conditions')
- ->map(fn ($value) => is_array($value)
- ? array_map(fn ($item) => Str::startsWith($item, 'field:') ? $item : $this->parseConfigValue($item), $value)
- : $this->parseConfigValue($value));
+ ->map(fn ($value, $key) => match (true) {
+ in_array($key, ['subject', 'body']) => $value,
+ is_array($value) => array_map(fn ($item) => Str::startsWith($item, 'field:') ? $item : $this->parseConfigValue($item), $value),
+ default => $this->parseConfigValue($value),
+ });
}
private function parseConfigValue($value): string
diff --git a/src/Forms/FormRepository.php b/src/Forms/FormRepository.php
index decee1c8982..c018018ea59 100644
--- a/src/Forms/FormRepository.php
+++ b/src/Forms/FormRepository.php
@@ -10,6 +10,7 @@
use Statamic\Exceptions\FormNotFoundException;
use Statamic\Facades\File;
use Statamic\Facades\Folder;
+use Statamic\Fieldtypes\AutocompleteEditor\Mentions;
use Statamic\Forms\Exporters\ExporterRepository;
use Statamic\Support\Arr;
use Statamic\Support\Str;
@@ -18,6 +19,7 @@ class FormRepository implements Contract
{
private $configs = [];
private $redirects = [];
+ private $additionalMentions = [];
/**
* Find a form.
@@ -143,6 +145,26 @@ public function redirect(string $form, Closure $callback)
return $this;
}
+ public function addMention(string $handle, string $label, Closure $value)
+ {
+ if (! str_contains($handle, '.') || ! Mentions::isValidValue($handle)) {
+ throw new InvalidArgumentException("Invalid mention handle [{$handle}]. It must contain a dot and only use letters, numbers, underscores, dashes and dots.");
+ }
+
+ if (str_starts_with($handle, 'form.') || str_starts_with($handle, 'site.') || str_starts_with($handle, 'globals.') || str_starts_with($handle, 'config.')) {
+ throw new InvalidArgumentException("Invalid mention handle [{$handle}]. The [form.], [site.], [globals.] and [config.] prefixes are reserved.");
+ }
+
+ $this->additionalMentions[$handle] = ['label' => $label, 'value' => $value];
+
+ return $this;
+ }
+
+ public function additionalMentions(): array
+ {
+ return $this->additionalMentions;
+ }
+
public function getSubmissionRedirect(SubmissionContract $submission)
{
$callback = $this->redirects[$submission->form()->handle()] ?? fn () => null;
diff --git a/src/Forms/SubmissionMentions.php b/src/Forms/SubmissionMentions.php
new file mode 100644
index 00000000000..abc3c4ba89c
--- /dev/null
+++ b/src/Forms/SubmissionMentions.php
@@ -0,0 +1,209 @@
+formFields()->fields() as $handle => $field) {
+ if ($field->fieldtype()->collectsValue()) {
+ $options[$handle] = __($field->display());
+ }
+ }
+
+ $others = [
+ 'form.title' => __('Form Title'),
+ 'site.name' => __('Site Name'),
+ 'site.url' => __('Site URL'),
+ 'config.app.name' => __('App Name'),
+ ];
+
+ foreach (FormFacade::additionalMentions() as $handle => $mention) {
+ $others[$handle] = __($mention['label']);
+ }
+
+ foreach (GlobalSet::all() as $set) {
+ foreach ($set->blueprint()?->fields()->all() ?? [] as $handle => $field) {
+ if (static::isGlobalFieldOffered($field->fieldtype(), $field->type())) {
+ $others["globals.{$set->handle()}.{$handle}"] = __($set->title()).': '.__($field->display());
+ }
+ }
+ }
+
+ asort($others, SORT_NATURAL | SORT_FLAG_CASE);
+
+ return $options + $others;
+ }
+
+ public static function handlesIn(string $content, bool $markdown = false): array
+ {
+ if (! $markdown) {
+ return Mentions::handles($content);
+ }
+
+ preg_match_all(Mentions::TOKEN, $content, $matches);
+
+ return array_values(array_unique($matches[1]));
+ }
+
+ public static function values(Submission $submission, array $handles, ?array $submissionData = null): array
+ {
+ $additional = FormFacade::additionalMentions();
+ $globals = [];
+ $values = [];
+
+ foreach ($handles as $handle) {
+ if ($handle === 'form.title') {
+ $value = $submission->form()->title();
+ } elseif ($handle === 'site.name') {
+ $value = $submission->site()->name();
+ } elseif ($handle === 'site.url') {
+ $value = $submission->site()->absoluteUrl();
+ } elseif ($handle === 'config.app.name') {
+ $value = config('app.name');
+ } elseif (isset($additional[$handle])) {
+ $value = $additional[$handle]['value']($submission);
+ } elseif (str_starts_with($handle, 'globals.')) {
+ $value = static::globalValue($submission, $handle, $globals);
+ } elseif (in_array($handle, self::EXCLUDED_FORM_VALUES) || ! $submission->form()->fields()->has($handle)) {
+ continue;
+ } else {
+ $submissionData ??= $submission->toAugmentedArray();
+ $value = static::formFieldValue($submission, $handle, $submissionData[$handle] ?? null);
+ }
+
+ $values[$handle] = static::mentionValue($value);
+ }
+
+ return $values;
+ }
+
+ public static function replace(Submission $submission, string $content, ?callable $using = null, ?array $submissionData = null): string
+ {
+ $values = static::values($submission, static::handlesIn($content), $submissionData);
+
+ return Mentions::replace($content, $values, using: $using);
+ }
+
+ public static function mentionValue(mixed $value): string
+ {
+ if ($value instanceof Value) {
+ $value = $value->value();
+ }
+
+ if ($value instanceof Builder) {
+ $value = $value->get();
+ }
+
+ if ($value instanceof LabeledValue) {
+ return (string) ($value->label() ?? $value->value());
+ }
+
+ if (static::isAugmentedOption($value)) {
+ return (string) ($value['label'] ?? $value['value']);
+ }
+
+ if ($value instanceof Asset) {
+ return (string) $value->absoluteUrl();
+ }
+
+ if (is_bool($value)) {
+ return $value ? __('Yes') : __('No');
+ }
+
+ if (is_iterable($value)) {
+ return collect($value)
+ ->map(fn ($item) => static::mentionValue($item))
+ ->filter(fn ($item) => $item !== '')
+ ->implode(', ');
+ }
+
+ if (is_scalar($value) || $value instanceof Stringable) {
+ return (string) $value;
+ }
+
+ return '';
+ }
+
+ private static function isAugmentedOption(mixed $value): bool
+ {
+ if (! is_array($value) || count($value) !== 3) {
+ return false;
+ }
+
+ return array_key_exists('key', $value) && array_key_exists('value', $value) && array_key_exists('label', $value);
+ }
+
+ private static function formFieldValue(Submission $submission, string $handle, mixed $value): mixed
+ {
+ $fieldtype = $submission->form()->fields()->get($handle)->fieldtype();
+
+ if (! $fieldtype instanceof FormUpload || $fieldtype->config('store')) {
+ return $value;
+ }
+
+ if ($value instanceof Value) {
+ $value = $value->value();
+ }
+
+ return collect(Arr::wrap($value))->map(fn ($path) => basename($path))->all();
+ }
+
+ private static function isGlobalFieldOffered($fieldtype, string $type): bool
+ {
+ return ! in_array($type, self::EXCLUDED_GLOBAL_FIELDTYPES) && ! $fieldtype instanceof Relationship;
+ }
+
+ private static function globalValue(Submission $submission, string $handle, array &$globals): mixed
+ {
+ [, $setHandle, $fieldHandle] = array_pad(explode('.', $handle, 3), 3, null);
+
+ if (! $setHandle || ! $fieldHandle) {
+ return null;
+ }
+
+ if (! array_key_exists($setHandle, $globals)) {
+ $set = GlobalSet::findByHandle($setHandle);
+ $site = $submission->site()->handle();
+
+ $globals[$setHandle] = $set && $set->existsIn($site)
+ ? $set->in($site)->toAugmentedArray()
+ : [];
+ }
+
+ $value = $globals[$setHandle][$fieldHandle] ?? null;
+
+ if ($value instanceof Value && $value->fieldtype() instanceof Markdown) {
+ return $value->raw();
+ }
+
+ return $value;
+ }
+}
diff --git a/tests/Forms/EmailTest.php b/tests/Forms/EmailTest.php
index 2d33db792cb..0c2dad86567 100644
--- a/tests/Forms/EmailTest.php
+++ b/tests/Forms/EmailTest.php
@@ -334,13 +334,14 @@ public function it_renders_the_body_in_the_automagic_email_instead_of_listing_th
$email = new Email($submission, [
'to' => 'test@test.com',
- 'body' => "Hello {{ name }},\nThanks for getting in touch.",
+ 'body' => "Hello [[ name ]],\n\nThanks for getting in touch.",
], Site::default());
$body = $email->render();
- $this->assertStringContainsString('Hello Jack Black,
assertStringContainsString('Thanks for getting in touch.', $body);
+ $this->assertEquals("Hello [[ name ]],\n\nThanks for getting in touch.", $email->getConfig()['body']);
+ $this->assertStringContainsString('
Hello Jack Black,
', $body); + $this->assertStringContainsString('Thanks for getting in touch.
', $body); $this->assertStringNotContainsString('Name:', $body); } @@ -357,7 +358,7 @@ public function it_escapes_submitted_values_in_the_automagic_email_body() $email = new Email($submission, [ 'to' => 'test@test.com', - 'body' => 'New submission from {{ name }}', + 'body' => 'New submission from [[ name ]]', ], Site::default()); $body = $email->render(); @@ -366,10 +367,211 @@ public function it_escapes_submitted_values_in_the_automagic_email_body() $this->assertStringContainsString('New submission from <script>alert(1)</script>', $body); } + #[Test] + public function it_does_not_parse_antlers_in_the_body() + { + $email = $this->makeEmailWithConfig(['body' => 'Hello {{ name }} {{ config:app.name }}'], build: false); + + $body = $email->render(); + + $this->assertStringContainsString('{{ name }}', $body); + $this->assertStringNotContainsString('Foo Bar', $body); + } + + #[Test] + public function it_escapes_submitted_markdown_and_html_in_the_body() + { + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ], + ]))->save(); + + $submission = $form->makeSubmission()->data(['name' => '[Reset](https://evil.test) **bold** hi']); + + $body = (new Email($submission, ['to' => 'test@test.com', 'body' => 'From [[ name ]]'], Site::default()))->render(); + + $this->assertStringNotContainsString('href="https://evil.test"', $body); + $this->assertStringNotContainsString('bold', $body); + $this->assertStringNotContainsString('hi', $body); + $this->assertStringContainsString('[Reset](https://evil.test) **bold** <b>hi</b>', $body); + } + + #[Test] + public function it_replaces_mentions_in_the_subject() + { + $email = $this->makeEmailWithConfig(['subject' => 'New message from [[ name ]] & *friends* \[[ name ]]']); + + $this->assertEquals('New message from Foo Bar & *friends* [[ name ]]', $email->subject); + } + + #[Test] + public function it_does_not_resolve_escaped_mentions_in_the_subject_or_body() + { + $calls = 0; + Form::addMention('shop.name', 'Shop', function () use (&$calls) { + $calls++; + + return 'Acme'; + }); + + $email = $this->makeEmailWithConfig([ + 'subject' => '\\[[ shop.name ]] [[ name ]]', + 'body' => '\\[\\[ shop.name \\]\\] [[ name ]]', + ], build: false); + + $html = $email->render(); + + $this->assertEquals('[[ shop.name ]] Foo Bar', $email->subject); + $this->assertStringContainsString('[[ shop.name ]]', $html); + $this->assertStringContainsString('Foo Bar', $html); + $this->assertStringNotContainsString('Acme', $html); + $this->assertSame(0, $calls); + } + + #[Test] + public function it_keeps_antlers_in_the_subject() + { + $email = $this->makeEmailWithConfig(['subject' => '{{ name | upper }} via [[ email ]]']); + + $this->assertEquals('FOO BAR via foo@bar.com', $email->subject); + } + + #[Test] + public function submitted_values_in_the_subject_are_not_parsed() + { + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ['handle' => 'secret', 'field' => ['type' => 'short_answer']], + ], + ]))->save(); + + $submission = $form->makeSubmission()->data(['name' => '[[ secret ]]', 'secret' => 'shh']); + + $email = tap(new Email($submission, ['to' => 'test@test.com', 'subject' => 'From {{ name }}'], Site::default()))->build(); + + $this->assertEquals('From [[ secret ]]', $email->subject); + } + + #[Test] + public function submitted_values_in_the_subject_are_verbatim_and_single_line() + { + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ], + ]))->save(); + + $makeSubject = fn ($name) => tap(new Email( + $form->makeSubmission()->data(['name' => $name]), + ['to' => 'test@test.com', 'subject' => 'Hi [[ name ]]'], + Site::default() + ))->build()->subject; + + $this->assertEquals('Hi A & {{ x }}', $makeSubject('A & {{ x }}')); + $this->assertEquals('Hi line one line two', $makeSubject("line one\r\n\r\nline two")); + } + + #[Test] + public function it_falls_back_to_the_default_subject_when_empty() + { + $this->assertEquals('Form Submission', $this->makeEmailWithConfig([])->subject); + $this->assertEquals('Form Submission', $this->makeEmailWithConfig(['subject' => ' '])->subject); + } + + #[Test] + public function it_resolves_site_app_and_global_mentions_in_the_subject_and_body() + { + config(['app.name' => 'My App']); + + $email = $this->makeEmailWithConfig([ + 'subject' => '[[ site.name ]] | [[ config.app.name ]] | [[ globals.company_information.name ]]', + 'body' => '[[ site.url ]] | [[ config.app.name ]] | [[ globals.company_information.email ]]', + ], build: false); + + $site = Site::default(); + + $html = $email->render(); + + $this->assertEquals($site->name().' | My App | Example Company', $email->subject); + $this->assertStringContainsString(''.$site->absoluteUrl().' | My App | info@example.com
', $html); + } + + #[Test] + public function it_resolves_the_form_title_mention_in_the_subject_and_body() + { + $email = $this->makeEmailWithConfig([ + 'subject' => 'New: [[ form.title ]]', + 'body' => 'From [[ form.title ]]', + ], build: false); + + $title = $email->getSubmission()->form()->title(); + + $html = $email->render(); + + $this->assertEquals('New: '.$title, $email->subject); + $this->assertStringContainsString('From '.$title.'
', $html); + } + + #[Test] + public function globals_mentions_use_the_submissions_site() + { + $this->setSites([ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr'], + ]); + + $set = GlobalSet::make()->handle('company_information')->sites(['en', 'fr'])->save(); + $set->makeLocalization('en')->data(['name' => 'Example Company'])->save(); + $set->makeLocalization('fr')->data(['name' => 'Société Exemple'])->save(); + + BlueprintRepository::partialMock(); + BlueprintRepository::shouldReceive('find')->with('globals.company_information') + ->andReturn(Blueprint::makeFromFields(['name' => ['type' => 'text']])); + + $form = tap(Form::make('test')->formFields(['fields' => []]))->save(); + $submission = $form->makeSubmission()->data([]); + $submission->site('fr'); + + $email = tap(new Email($submission, [ + 'to' => 'test@test.com', + 'subject' => '[[ globals.company_information.name ]]', + ], Site::get('fr')))->build(); + + $this->assertEquals('Société Exemple', $email->subject); + } + + #[Test] + public function markdown_globals_mentions_use_the_raw_text() + { + $set = GlobalSet::make()->handle('company_information')->sites(['en'])->save(); + $set->makeLocalization('en')->data(['bio' => 'We make *things*'])->save(); + + BlueprintRepository::partialMock(); + BlueprintRepository::shouldReceive('find')->with('globals.company_information') + ->andReturn(Blueprint::makeFromFields(['bio' => ['type' => 'markdown']])); + + $form = tap(Form::make('test')->formFields(['fields' => []]))->save(); + $submission = $form->makeSubmission()->data([]); + + $email = new Email($submission, [ + 'to' => 'test@test.com', + 'subject' => '[[ globals.company_information.bio ]]', + 'body' => '[[ globals.company_information.bio ]]', + ], Site::default()); + + $html = $email->render(); + + $this->assertEquals('We make *things*', $email->subject); + $this->assertStringNotContainsString('<p>', $html); + $this->assertStringContainsString('We make *things*', $html); + } + #[Test] public function it_uses_the_custom_view_instead_of_the_body_when_one_is_configured() { - $email = $this->makeEmailWithConfig(['body' => 'Hello {{ name }}', 'html' => 'emails.custom']); + $email = $this->makeEmailWithConfig(['body' => 'Hello [[ name ]]', 'html' => 'emails.custom']); $this->assertEquals('emails.custom', $email->view); } @@ -628,7 +830,7 @@ private function makeEmailWithSubmission(Submission $submission) return tap(new Email($submission, ['to' => 'test@test.com'], Site::default()))->build(); } - private function makeEmailWithConfig(array $config) + private function makeEmailWithConfig(array $config, bool $build = true) { $globalSet = GlobalSet::make()->handle('company_information')->save(); $globalSet->in('en')->data([ @@ -656,11 +858,13 @@ private function makeEmailWithConfig(array $config) 'email' => 'foo@bar.com', ]); - return tap(new Email( + $email = new Email( $submission, array_merge(['to' => 'test@test.com'], $config), Site::default() - ))->build(); + ); + + return $build ? tap($email)->build() : $email; } #[Test] diff --git a/tests/Forms/SubmissionMentionsTest.php b/tests/Forms/SubmissionMentionsTest.php new file mode 100644 index 00000000000..9ab408efb90 --- /dev/null +++ b/tests/Forms/SubmissionMentionsTest.php @@ -0,0 +1,545 @@ +makeGlobalSet('company', 'Company', [ + 'name' => ['type' => 'text', 'display' => 'Name'], + ]); + Form::addMention('shop.owner', 'Owner', fn () => 'x'); + + $form = $this->makeForm(); + + $this->assertSame([ + 'zebra' => 'Zebra', + 'apple' => 'Apple', + 'config.app.name' => 'App Name', + 'globals.company.name' => 'Company: Name', + 'form.title' => 'Form Title', + 'shop.owner' => 'Owner', + 'site.name' => 'Site Name', + 'site.url' => 'Site URL', + ], SubmissionMentions::options($form)); + } + + #[Test] + public function it_excludes_form_fields_that_do_not_collect_a_value() + { + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ['handle' => 'notice', 'field' => ['type' => 'banner']], + ], + ]))->save(); + + $options = SubmissionMentions::options($form); + + $this->assertArrayHasKey('name', $options); + $this->assertArrayNotHasKey('notice', $options); + } + + #[Test] + public function it_excludes_unsuitable_global_fieldtypes() + { + TestRelationshipFieldtype::register(); + + $this->makeGlobalSet('company', 'Company', [ + 'name' => ['type' => 'text'], + 'photos' => ['type' => 'assets'], + 'body' => ['type' => 'bard'], + 'items' => ['type' => 'grid', 'fields' => []], + 'secret' => ['type' => 'hidden'], + 'related' => ['type' => 'entries'], + 'custom' => ['type' => 'test_relationship'], + 'logo' => ['type' => 'icon'], + ]); + + $handles = array_keys(SubmissionMentions::options($this->makeForm())); + + $this->assertContains('globals.company.name', $handles); + $this->assertContains('globals.company.photos', $handles); + $this->assertNotContains('globals.company.body', $handles); + $this->assertNotContains('globals.company.items', $handles); + $this->assertNotContains('globals.company.secret', $handles); + $this->assertNotContains('globals.company.related', $handles); + $this->assertNotContains('globals.company.custom', $handles); + $this->assertNotContains('globals.company.logo', $handles); + } + + #[Test] + public function it_skips_global_sets_without_a_blueprint() + { + BlueprintRepository::partialMock(); + BlueprintRepository::shouldReceive('find')->with('globals.company')->andReturnNull(); + tap(GlobalSet::make('company')->title('Company'))->save(); + + $this->assertArrayNotHasKey('globals.company.name', SubmissionMentions::options($this->makeForm())); + $this->assertCount(6, SubmissionMentions::options($this->makeForm())); + } + + #[Test] + public function it_does_not_call_added_closures_when_building_options() + { + $called = false; + Form::addMention('shop.owner', 'Owner', function () use (&$called) { + $called = true; + }); + + SubmissionMentions::options($this->makeForm()); + + $this->assertFalse($called); + } + + #[Test] + public function it_only_calls_added_closures_that_are_referenced() + { + $calls = []; + Form::addMention('shop.owner', 'Owner', function () use (&$calls) { + $calls[] = 'owner'; + + return 'Jason'; + }); + Form::addMention('shop.other', 'Other', function () use (&$calls) { + $calls[] = 'other'; + + return 'Nope'; + }); + + $values = SubmissionMentions::values($this->makeSubmission(), ['shop.owner']); + + $this->assertSame(['shop.owner' => 'Jason'], $values); + $this->assertSame(['owner'], $calls); + } + + #[Test] + public function it_throws_for_invalid_added_handles() + { + foreach (['owner', 'shop owner.name', 'shop.na!me', 'site.custom', 'form.custom', 'globals.company.name', 'config.custom'] as $handle) { + try { + Form::addMention($handle, 'Label', fn () => 'x'); + $this->fail("Handle [{$handle}] should have been rejected."); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString($handle, $e->getMessage()); + } + } + } + + #[Test] + public function it_has_no_additional_mentions_by_default() + { + $this->assertSame([], Form::additionalMentions()); + } + + #[Test] + public function it_ignores_escaped_literals_when_extracting_handles() + { + $this->assertSame( + ['name'], + SubmissionMentions::handlesIn('\\[[ shop.name ]] [[ name ]]') + ); + $this->assertSame([], SubmissionMentions::handlesIn('\\[\\[ shop.owner \\]\\]', markdown: true)); + } + + #[Test] + public function it_does_not_resolve_escaped_literals_in_replace() + { + $calls = 0; + Form::addMention('shop.name', 'Shop', function () use (&$calls) { + $calls++; + + return 'Acme'; + }); + + $this->assertSame( + '[[ shop.name ]]', + SubmissionMentions::replace($this->makeSubmission(), '\\[[ shop.name ]]') + ); + $this->assertSame(0, $calls); + + $this->assertSame( + '[[ shop.name ]] Granny Smith', + SubmissionMentions::replace($this->makeSubmission(), '\\[[ shop.name ]] [[ apple ]]') + ); + $this->assertSame(0, $calls); + } + + #[Test] + public function it_extracts_handles_from_content() + { + $this->assertSame( + ['name', 'site.url'], + SubmissionMentions::handlesIn('Hi [[ name ]] at [[site.url]]') + ); + $this->assertSame( + ['name', 'globals.a.b'], + SubmissionMentions::handlesIn('[[name]] [[ globals.a.b ]] [ nope ]', markdown: true) + ); + } + + #[Test] + public function it_resolves_only_the_requested_form_values() + { + $values = SubmissionMentions::values($this->makeSubmission(), ['apple', 'id', 'date', 'form', 'missing']); + + $this->assertSame(['apple' => 'Granny Smith'], $values); + } + + #[Test] + public function it_uses_provided_submission_data() + { + $values = SubmissionMentions::values($this->makeSubmission(), ['apple'], ['apple' => 'Provided']); + + $this->assertSame(['apple' => 'Provided'], $values); + } + + #[Test] + public function it_replaces_mentions_in_content() + { + $this->assertSame( + 'Hi Granny Smith, from Stripes', + SubmissionMentions::replace($this->makeSubmission(), 'Hi [[ apple ]], from [[zebra]]') + ); + } + + #[Test] + public function it_decodes_escaped_brackets_when_replacing_without_mentions() + { + $this->assertSame('Not [[a mention]]', SubmissionMentions::replace($this->makeSubmission(), 'Not \\[[a mention]]')); + } + + #[Test] + public function it_runs_the_replace_callback_without_seeing_submitted_values() + { + $seen = null; + + $result = SubmissionMentions::replace($this->makeSubmission(), '[[apple]] {{ x }}', function ($text) use (&$seen) { + $seen = $text; + + return str_replace('{{ x }}', 'X', $text); + }); + + $this->assertStringNotContainsString('Granny Smith', $seen); + $this->assertStringContainsString('{{ x }}', $seen); + $this->assertStringContainsString('Granny Smith', $result); + $this->assertStringContainsString(' X', $result); + } + + #[Test] + public function it_uses_provided_submission_data_when_replacing() + { + $this->assertSame( + 'Provided', + SubmissionMentions::replace($this->makeSubmission(), '[[apple]]', submissionData: ['apple' => 'Provided']) + ); + } + + #[Test] + public function it_resolves_site_and_added_values() + { + config(['app.name' => 'My App']); + $this->setSites(['en' => ['url' => 'http://localhost/', 'locale' => 'en_US', 'name' => 'Test Site']]); + + $values = SubmissionMentions::values($this->makeSubmission(), ['site.name', 'site.url', 'config.app.name']); + + $this->assertSame([ + 'site.name' => 'Test Site', + 'site.url' => 'http://localhost', + 'config.app.name' => 'My App', + ], $values); + } + + #[Test] + public function it_resolves_the_form_title() + { + $submission = $this->makeSubmission(); + $submission->form()->title('Contact Us')->save(); + + $this->assertSame(['form.title' => 'Contact Us'], SubmissionMentions::values($submission, ['form.title'])); + } + + #[Test] + public function it_passes_the_submission_to_added_closures() + { + $received = null; + Form::addMention('shop.owner', 'Owner', function ($submission) use (&$received) { + $received = $submission; + + return 'Jason'; + }); + + $submission = $this->makeSubmission(); + + $this->assertSame(['shop.owner' => 'Jason'], SubmissionMentions::values($submission, ['shop.owner'])); + $this->assertSame($submission, $received); + } + + #[Test] + public function it_makes_the_site_url_absolute() + { + $this->setSites(['en' => ['url' => '/', 'locale' => 'en_US', 'name' => 'English']]); + + $values = SubmissionMentions::values($this->makeSubmission(), ['site.url']); + + $this->assertStringStartsWith('http', $values['site.url']); + } + + #[Test] + public function it_resolves_globals_localized_to_the_submission_site() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en_US', 'name' => 'English'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr_FR', 'name' => 'French'], + ]); + + $set = $this->makeGlobalSet('company', 'Company', ['name' => ['type' => 'text']], ['en', 'fr']); + $set->makeLocalization('en')->data(['name' => 'Company EN'])->save(); + $set->makeLocalization('fr')->data(['name' => 'Company FR'])->save(); + + $submission = $this->makeSubmission()->site('fr'); + + $this->assertSame( + ['globals.company.name' => 'Company FR'], + SubmissionMentions::values($submission, ['globals.company.name']) + ); + } + + #[Test] + public function it_resolves_markdown_globals_to_their_raw_text() + { + $this->makeGlobalSet('company', 'Company', ['bio' => ['type' => 'markdown']]) + ->makeLocalization('en')->data(['bio' => 'Some *emphasis* & more'])->save(); + + $this->assertArrayHasKey('globals.company.bio', SubmissionMentions::options($this->makeForm())); + + $this->assertSame( + ['globals.company.bio' => 'Some *emphasis* & more'], + SubmissionMentions::values($this->makeSubmission(), ['globals.company.bio']) + ); + } + + #[Test] + public function it_resolves_unknown_or_unlocalized_globals_to_empty_strings() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en_US', 'name' => 'English'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr_FR', 'name' => 'French'], + ]); + + $set = $this->makeGlobalSet('company', 'Company', ['name' => ['type' => 'text']]); + $set->makeLocalization('en')->data(['name' => 'Company EN'])->save(); + + $values = SubmissionMentions::values($this->makeSubmission()->site('fr'), [ + 'globals.company.name', 'globals.nope.name', 'globals.company', + ]); + + $this->assertSame([ + 'globals.company.name' => '', + 'globals.nope.name' => '', + 'globals.company' => '', + ], $values); + } + + #[Test] + public function it_converts_values_to_text() + { + $asset = Mockery::mock(AssetContract::class); + $asset->shouldReceive('absoluteUrl')->andReturn('http://localhost/assets/file.pdf'); + + $this->assertSame('Hello', SubmissionMentions::mentionValue('Hello')); + $this->assertSame('5', SubmissionMentions::mentionValue(5)); + $this->assertSame('Hello', SubmissionMentions::mentionValue(new Value('Hello'))); + $this->assertSame('Red', SubmissionMentions::mentionValue(new LabeledValue('red', 'Red'))); + $this->assertSame('red', SubmissionMentions::mentionValue(new LabeledValue('red', null))); + $this->assertSame('Yes', SubmissionMentions::mentionValue(true)); + $this->assertSame('No', SubmissionMentions::mentionValue(false)); + $this->assertSame('a, b, c', SubmissionMentions::mentionValue(['a', '', ['b', null], new LabeledValue('c', 'c')])); + $this->assertSame($asset->absoluteUrl(), SubmissionMentions::mentionValue($asset)); + $this->assertSame($asset->absoluteUrl(), SubmissionMentions::mentionValue(new Value($asset))); + $this->assertSame('', SubmissionMentions::mentionValue(null)); + $this->assertSame('', SubmissionMentions::mentionValue(new \stdClass)); + $this->assertSame('str', SubmissionMentions::mentionValue(new class + { + public function __toString() + { + return 'str'; + } + })); + } + + #[Test] + public function it_prints_the_label_of_each_selected_option() + { + $options = ['red' => 'Red', 'blue' => 'Blue', 'green' => 'Green']; + + $form = tap(Form::make('choices')->formFields([ + 'fields' => [ + ['handle' => 'boxes', 'field' => ['type' => 'checkboxes', 'options' => $options]], + ['handle' => 'multi', 'field' => ['type' => 'select', 'multiple' => true, 'options' => $options]], + ['handle' => 'single', 'field' => ['type' => 'select', 'options' => $options]], + ['handle' => 'radio', 'field' => ['type' => 'radio', 'options' => $options]], + ['handle' => 'buttons', 'field' => ['type' => 'button_group', 'options' => $options]], + ], + ]))->save(); + + $submission = $form->makeSubmission()->data([ + 'boxes' => ['red', 'blue'], + 'multi' => ['red', 'blue'], + 'single' => 'green', + 'radio' => 'green', + 'buttons' => 'green', + ]); + + $this->assertSame([ + 'boxes' => 'Red, Blue', + 'multi' => 'Red, Blue', + 'single' => 'Green', + 'radio' => 'Green', + 'buttons' => 'Green', + ], SubmissionMentions::values($submission, ['boxes', 'multi', 'single', 'radio', 'buttons'])); + + $this->assertSame( + 'Red, Blue | Red, Blue | Green', + SubmissionMentions::replace($submission, '[[ boxes ]] | [[ multi ]] | [[ single ]]') + ); + + $email = new Email($submission, ['to' => 'test@test.com', 'subject' => 'Picked [[ boxes ]]', 'body' => 'You chose [[ multi ]]'], Site::default()); + + $this->assertStringContainsString('You chose Red, Blue', $email->render()); + $this->assertSame('Picked Red, Blue', $email->subject); + } + + #[Test] + public function it_prints_the_label_of_each_selected_option_in_a_checkboxes_global() + { + $this->makeGlobalSet('company', 'Company', [ + 'services' => ['type' => 'checkboxes', 'options' => ['red' => 'Red', 'blue' => 'Blue', 'green' => 'Green']], + ])->makeLocalization('en')->data(['services' => ['red', 'blue']])->save(); + + $submission = $this->makeSubmission(); + + $this->assertSame( + ['globals.company.services' => 'Red, Blue'], + SubmissionMentions::values($submission, ['globals.company.services']) + ); + + $email = new Email($submission, [ + 'to' => 'test@test.com', + 'subject' => 'Offering [[ globals.company.services ]]', + 'body' => 'We do [[ globals.company.services ]]', + ], Site::default()); + + $this->assertStringContainsString('We do Red, Blue', $email->render()); + $this->assertSame('Offering Red, Blue', $email->subject); + } + + #[Test] + public function it_only_collapses_arrays_with_exactly_the_option_shape() + { + $this->assertSame('Red', SubmissionMentions::mentionValue(['key' => 'red', 'value' => 'red', 'label' => 'Red'])); + $this->assertSame('red', SubmissionMentions::mentionValue(['key' => 'red', 'value' => 'red', 'label' => null])); + $this->assertSame('a, b, c, d', SubmissionMentions::mentionValue(['key' => 'a', 'value' => 'b', 'label' => 'c', 'extra' => 'd'])); + } + + #[Test] + public function it_prints_only_filenames_for_non_stored_uploads() + { + $submission = $this->makeUploadSubmission(['type' => 'form_upload'], ['abc123/cv.pdf', 'abc123/photo.jpg']); + + $this->assertSame(['upload' => 'cv.pdf, photo.jpg'], SubmissionMentions::values($submission, ['upload'])); + $this->assertSame('cv.pdf, photo.jpg', SubmissionMentions::replace($submission, '[[upload]]')); + } + + #[Test] + public function it_prints_the_filename_for_a_single_non_stored_upload() + { + $submission = $this->makeUploadSubmission(['type' => 'form_upload', 'max_files' => 1], 'abc123/cv.pdf'); + + $this->assertSame('cv.pdf', SubmissionMentions::replace($submission, '[[upload]]')); + } + + #[Test] + public function it_prints_the_url_for_uploads_stored_as_assets() + { + $asset = Mockery::mock(AssetContract::class); + $asset->shouldReceive('absoluteUrl')->andReturn('http://localhost/assets/cv.pdf'); + + $submission = $this->makeUploadSubmission(['type' => 'form_upload', 'store' => true, 'container' => 'assets'], 'cv.pdf'); + + $this->assertSame( + 'http://localhost/assets/cv.pdf', + SubmissionMentions::replace($submission, '[[upload]]', submissionData: ['upload' => new Value($asset)]) + ); + } + + private function makeUploadSubmission(array $field, mixed $value) + { + $form = tap(Form::make('uploads')->formFields([ + 'fields' => [['handle' => 'upload', 'field' => $field + ['display' => 'Upload']]], + ]))->save(); + + return $form->makeSubmission()->data(['upload' => $value]); + } + + private function makeForm() + { + return tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'zebra', 'field' => ['type' => 'short_answer', 'display' => 'Zebra']], + ['handle' => 'apple', 'field' => ['type' => 'short_answer', 'display' => 'Apple']], + ], + ]))->save(); + } + + private function makeSubmission() + { + return $this->makeForm()->makeSubmission()->data(['zebra' => 'Stripes', 'apple' => 'Granny Smith']); + } + + private function makeGlobalSet(string $handle, string $title, array $fields, array $sites = ['en']) + { + $blueprint = Blueprint::makeFromFields($fields)->setHandle($handle)->setNamespace('globals'); + + BlueprintRepository::partialMock(); + BlueprintRepository::shouldReceive('find')->with('globals.'.$handle)->andReturn($blueprint); + + return tap(GlobalSet::make($handle)->title($title)->sites($sites))->save(); + } +} + +class TestRelationshipFieldtype extends Relationship +{ + protected static $handle = 'test_relationship'; + + protected function toItemArray($id) + { + return []; + } + + public function getIndexItems($request) + { + return collect(); + } +}