From 78e8f91652986433136926a1f87f65d0e48f0e5f Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 14 Oct 2026 13:03:27 -0400 Subject: [PATCH 1/6] Add reusable form submission mention helpers Co-Authored-By: Claude Opus 5.5 --- src/Facades/Form.php | 2 + src/Forms/Connectors/Connector.php | 11 + src/Forms/FormRepository.php | 18 ++ src/Forms/SubmissionMentions.php | 155 +++++++++++++ src/Providers/AppServiceProvider.php | 2 + tests/Forms/SubmissionMentionsTest.php | 303 +++++++++++++++++++++++++ 6 files changed, 491 insertions(+) create mode 100644 src/Forms/SubmissionMentions.php create mode 100644 tests/Forms/SubmissionMentionsTest.php diff --git a/src/Facades/Form.php b/src/Facades/Form.php index dd70edec0e4..cd28ed447fc 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 registerMention(string $handle, string $label, \Closure $value) + * @method static array mentions() * @method static \Closure getSubmissionRedirect(Submission $submission) * @method static ExporterRepository exporters() * diff --git a/src/Forms/Connectors/Connector.php b/src/Forms/Connectors/Connector.php index 40b2673b36c..6544ea29a2e 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,16 @@ protected function processConnection(array $connection): array return $this->blueprint()->fields()->addValues($connection)->process()->values()->all(); } + protected function mentionOptions(): array + { + return SubmissionMentions::options($this->form()); + } + + protected function mentionValues(Submission $submission, string ...$contents): array + { + return SubmissionMentions::values($submission, SubmissionMentions::handlesIn(...$contents)); + } + public function routes(Router $router): void { // diff --git a/src/Forms/FormRepository.php b/src/Forms/FormRepository.php index decee1c8982..86ec4fb325f 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 $mentions = []; /** * Find a form. @@ -143,6 +145,22 @@ public function redirect(string $form, Closure $callback) return $this; } + public function registerMention(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."); + } + + $this->mentions[$handle] = ['label' => $label, 'value' => $value]; + + return $this; + } + + public function mentions(): array + { + return $this->mentions; + } + 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..a738c5c3ba7 --- /dev/null +++ b/src/Forms/SubmissionMentions.php @@ -0,0 +1,155 @@ +formFields()->fields() as $handle => $field) { + if ($field->fieldtype()->collectsValue()) { + $options[$handle] = __($field->display()); + } + } + + $others = [ + 'site.name' => __('Site Name'), + 'site.url' => __('Site URL'), + ]; + + foreach (FormFacade::mentions() 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 ...$contents): array + { + preg_match_all(Mentions::TOKEN, implode("\n", $contents), $matches); + + return array_values(array_unique($matches[1])); + } + + public static function values(Submission $submission, array $handles, ?array $submissionData = null): array + { + $registered = FormFacade::mentions(); + $globals = []; + $values = []; + + foreach ($handles as $handle) { + if ($handle === 'site.name') { + $value = $submission->site()->name(); + } elseif ($handle === 'site.url') { + $value = $submission->site()->absoluteUrl(); + } elseif (isset($registered[$handle])) { + $value = $registered[$handle]['value'](); + } 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 = $submissionData[$handle] ?? null; + } + + $values[$handle] = static::mentionValue($value); + } + + return $values; + } + + 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 ($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 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() + : []; + } + + return $globals[$setHandle][$fieldHandle] ?? null; + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index a71575945a2..e50983c17b9 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -148,6 +148,8 @@ public function boot() return ! $route || $request->is($route.'/*'); }); + Facades\Form::registerMention('app.name', 'App Name', fn () => config('app.name')); + $this->addAboutCommandInfo(); $this->registerElevatedSessionMacros(); diff --git a/tests/Forms/SubmissionMentionsTest.php b/tests/Forms/SubmissionMentionsTest.php new file mode 100644 index 00000000000..79e4f914f11 --- /dev/null +++ b/tests/Forms/SubmissionMentionsTest.php @@ -0,0 +1,303 @@ +makeGlobalSet('company', 'Company', [ + 'name' => ['type' => 'text', 'display' => 'Name'], + ]); + Form::registerMention('shop.owner', 'Owner', fn () => 'x'); + + $form = $this->makeForm(); + + $this->assertSame([ + 'zebra' => 'Zebra', + 'apple' => 'Apple', + 'app.name' => 'App Name', + 'globals.company.name' => 'Company: Name', + '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'], + ]); + + $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); + } + + #[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(5, SubmissionMentions::options($this->makeForm())); + } + + #[Test] + public function it_does_not_call_registered_closures_when_building_options() + { + $called = false; + Form::registerMention('shop.owner', 'Owner', function () use (&$called) { + $called = true; + }); + + SubmissionMentions::options($this->makeForm()); + + $this->assertFalse($called); + } + + #[Test] + public function it_only_calls_registered_closures_that_are_referenced() + { + $calls = []; + Form::registerMention('shop.owner', 'Owner', function () use (&$calls) { + $calls[] = 'owner'; + + return 'Jason'; + }); + Form::registerMention('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_registered_handles() + { + foreach (['owner', 'shop owner.name', 'shop.na!me'] as $handle) { + try { + Form::registerMention($handle, 'Label', fn () => 'x'); + $this->fail("Handle [{$handle}] should have been rejected."); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString($handle, $e->getMessage()); + } + } + } + + #[Test] + public function it_extracts_handles_from_content() + { + $this->assertSame( + ['name', 'site.url', 'globals.a.b'], + SubmissionMentions::handlesIn('Hi [[ name ]] at [[site.url]]', '[[name]] [[ globals.a.b ]] [ nope ]') + ); + } + + #[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_resolves_site_and_registered_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', 'app.name']); + + $this->assertSame([ + 'site.name' => 'Test Site', + 'site.url' => 'http://localhost', + 'app.name' => 'My App', + ], $values); + } + + #[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_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'; + } + })); + } + + 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(); + } +} From 07cfb8d62e2efafe1c9fc51865df5475f6c4cb0e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 14 Oct 2026 13:06:47 -0400 Subject: [PATCH 2/6] Use the autocomplete editor for the email connector's subject and message Co-Authored-By: Claude Opus 5.5 --- lang/en/messages.php | 2 +- .../views/forms/automagic-email.antlers.html | 2 +- src/Forms/Connectors/Email.php | 8 +- src/Forms/Email.php | 37 ++++- tests/Forms/EmailTest.php | 154 +++++++++++++++++- 5 files changed, 185 insertions(+), 18 deletions(-) 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/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..4183cebfbcf 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,30 @@ 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, + SubmissionMentions::handlesIn($subject, $body), + $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 +211,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 +338,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/tests/Forms/EmailTest.php b/tests/Forms/EmailTest.php index 2d33db792cb..de1e79f7071 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,145 @@ 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_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 ]] | [[ app.name ]] | [[ globals.company_information.name ]]', + 'body' => '[[ site.url ]] | [[ 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 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 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 +764,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 +792,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] From 540a97179e943eee6910d6d0eb56178aa8801c8c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 14 Oct 2026 13:23:11 -0400 Subject: [PATCH 3/6] Use raw markdown for global mentions, exclude icons, and reserve mention prefixes Co-Authored-By: Claude Opus 5.5 --- src/Forms/FormRepository.php | 4 ++++ src/Forms/SubmissionMentions.php | 11 +++++++++-- tests/Forms/EmailTest.php | 26 ++++++++++++++++++++++++++ tests/Forms/SubmissionMentionsTest.php | 18 +++++++++++++++++- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/Forms/FormRepository.php b/src/Forms/FormRepository.php index 86ec4fb325f..a1f6c9ebaea 100644 --- a/src/Forms/FormRepository.php +++ b/src/Forms/FormRepository.php @@ -151,6 +151,10 @@ public function registerMention(string $handle, string $label, Closure $value) 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, 'site.') || str_starts_with($handle, 'globals.')) { + throw new InvalidArgumentException("Invalid mention handle [{$handle}]. The [site.] and [globals.] prefixes are reserved."); + } + $this->mentions[$handle] = ['label' => $label, 'value' => $value]; return $this; diff --git a/src/Forms/SubmissionMentions.php b/src/Forms/SubmissionMentions.php index a738c5c3ba7..073756c4439 100644 --- a/src/Forms/SubmissionMentions.php +++ b/src/Forms/SubmissionMentions.php @@ -11,6 +11,7 @@ use Statamic\Fields\LabeledValue; use Statamic\Fields\Value; use Statamic\Fieldtypes\AutocompleteEditor\Mentions; +use Statamic\Fieldtypes\Markdown; use Statamic\Fieldtypes\Relationship; use Stringable; @@ -20,7 +21,7 @@ class SubmissionMentions { private const EXCLUDED_GLOBAL_FIELDTYPES = [ 'bard', 'replicator', 'grid', 'group', 'table', 'array', 'yaml', 'code', - 'section', 'spacer', 'revealer', 'html', 'hidden', + 'section', 'spacer', 'revealer', 'html', 'hidden', 'icon', ]; private const EXCLUDED_FORM_VALUES = ['id', 'date', 'form']; @@ -150,6 +151,12 @@ private static function globalValue(Submission $submission, string $handle, arra : []; } - return $globals[$setHandle][$fieldHandle] ?? null; + $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 de1e79f7071..f9e961e6a96 100644 --- a/tests/Forms/EmailTest.php +++ b/tests/Forms/EmailTest.php @@ -502,6 +502,32 @@ public function globals_mentions_use_the_submissions_site() $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() { diff --git a/tests/Forms/SubmissionMentionsTest.php b/tests/Forms/SubmissionMentionsTest.php index 79e4f914f11..03e384ce4bb 100644 --- a/tests/Forms/SubmissionMentionsTest.php +++ b/tests/Forms/SubmissionMentionsTest.php @@ -71,6 +71,7 @@ public function it_excludes_unsuitable_global_fieldtypes() 'secret' => ['type' => 'hidden'], 'related' => ['type' => 'entries'], 'custom' => ['type' => 'test_relationship'], + 'logo' => ['type' => 'icon'], ]); $handles = array_keys(SubmissionMentions::options($this->makeForm())); @@ -82,6 +83,7 @@ public function it_excludes_unsuitable_global_fieldtypes() $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] @@ -132,7 +134,7 @@ public function it_only_calls_registered_closures_that_are_referenced() #[Test] public function it_throws_for_invalid_registered_handles() { - foreach (['owner', 'shop owner.name', 'shop.na!me'] as $handle) { + foreach (['owner', 'shop owner.name', 'shop.na!me', 'site.custom', 'globals.company.name'] as $handle) { try { Form::registerMention($handle, 'Label', fn () => 'x'); $this->fail("Handle [{$handle}] should have been rejected."); @@ -212,6 +214,20 @@ public function it_resolves_globals_localized_to_the_submission_site() ); } + #[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() { From 165a048d588248df526ef5b672e58478c2240162 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 14 Oct 2026 18:57:31 -0400 Subject: [PATCH 4/6] Replace Connector::mentionValues() with a public SubmissionMentions::replace() helper Co-Authored-By: Claude Opus 5.5 --- src/Forms/Connectors/Connector.php | 5 ---- src/Forms/SubmissionMentions.php | 7 +++++ tests/Forms/SubmissionMentionsTest.php | 41 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/Forms/Connectors/Connector.php b/src/Forms/Connectors/Connector.php index 6544ea29a2e..68fc5780d23 100644 --- a/src/Forms/Connectors/Connector.php +++ b/src/Forms/Connectors/Connector.php @@ -238,11 +238,6 @@ protected function mentionOptions(): array return SubmissionMentions::options($this->form()); } - protected function mentionValues(Submission $submission, string ...$contents): array - { - return SubmissionMentions::values($submission, SubmissionMentions::handlesIn(...$contents)); - } - public function routes(Router $router): void { // diff --git a/src/Forms/SubmissionMentions.php b/src/Forms/SubmissionMentions.php index 073756c4439..8d29bdc80ec 100644 --- a/src/Forms/SubmissionMentions.php +++ b/src/Forms/SubmissionMentions.php @@ -93,6 +93,13 @@ public static function values(Submission $submission, array $handles, ?array $su 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) { diff --git a/tests/Forms/SubmissionMentionsTest.php b/tests/Forms/SubmissionMentionsTest.php index 03e384ce4bb..14023dbf145 100644 --- a/tests/Forms/SubmissionMentionsTest.php +++ b/tests/Forms/SubmissionMentionsTest.php @@ -169,6 +169,47 @@ public function it_uses_provided_submission_data() $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_registered_values() { From cf1ae42e79a7f563ffeb790cb57d487d5fbb0d96 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 14 Oct 2026 18:59:02 -0400 Subject: [PATCH 5/6] Add a form.title mention and pass the submission to registered mentions Co-Authored-By: Claude Opus 5.5 --- src/Forms/FormRepository.php | 4 ++-- src/Forms/SubmissionMentions.php | 7 ++++-- tests/Forms/EmailTest.php | 16 ++++++++++++++ tests/Forms/SubmissionMentionsTest.php | 30 ++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/Forms/FormRepository.php b/src/Forms/FormRepository.php index a1f6c9ebaea..8434feafcc3 100644 --- a/src/Forms/FormRepository.php +++ b/src/Forms/FormRepository.php @@ -151,8 +151,8 @@ public function registerMention(string $handle, string $label, Closure $value) 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, 'site.') || str_starts_with($handle, 'globals.')) { - throw new InvalidArgumentException("Invalid mention handle [{$handle}]. The [site.] and [globals.] prefixes are reserved."); + if (str_starts_with($handle, 'form.') || str_starts_with($handle, 'site.') || str_starts_with($handle, 'globals.')) { + throw new InvalidArgumentException("Invalid mention handle [{$handle}]. The [form.], [site.] and [globals.] prefixes are reserved."); } $this->mentions[$handle] = ['label' => $label, 'value' => $value]; diff --git a/src/Forms/SubmissionMentions.php b/src/Forms/SubmissionMentions.php index 8d29bdc80ec..da0eac9753e 100644 --- a/src/Forms/SubmissionMentions.php +++ b/src/Forms/SubmissionMentions.php @@ -37,6 +37,7 @@ public static function options(Form $form): array } $others = [ + 'form.title' => __('Form Title'), 'site.name' => __('Site Name'), 'site.url' => __('Site URL'), ]; @@ -72,12 +73,14 @@ public static function values(Submission $submission, array $handles, ?array $su $values = []; foreach ($handles as $handle) { - if ($handle === 'site.name') { + 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 (isset($registered[$handle])) { - $value = $registered[$handle]['value'](); + $value = $registered[$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)) { diff --git a/tests/Forms/EmailTest.php b/tests/Forms/EmailTest.php index f9e961e6a96..998dbe3aed4 100644 --- a/tests/Forms/EmailTest.php +++ b/tests/Forms/EmailTest.php @@ -474,6 +474,22 @@ public function it_resolves_site_app_and_global_mentions_in_the_subject_and_body $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() { diff --git a/tests/Forms/SubmissionMentionsTest.php b/tests/Forms/SubmissionMentionsTest.php index 14023dbf145..1a011e45602 100644 --- a/tests/Forms/SubmissionMentionsTest.php +++ b/tests/Forms/SubmissionMentionsTest.php @@ -36,6 +36,7 @@ public function it_lists_form_fields_first_then_the_rest_alphabetized_by_label() 'apple' => 'Apple', 'app.name' => 'App Name', 'globals.company.name' => 'Company: Name', + 'form.title' => 'Form Title', 'shop.owner' => 'Owner', 'site.name' => 'Site Name', 'site.url' => 'Site URL', @@ -94,7 +95,7 @@ public function it_skips_global_sets_without_a_blueprint() tap(GlobalSet::make('company')->title('Company'))->save(); $this->assertArrayNotHasKey('globals.company.name', SubmissionMentions::options($this->makeForm())); - $this->assertCount(5, SubmissionMentions::options($this->makeForm())); + $this->assertCount(6, SubmissionMentions::options($this->makeForm())); } #[Test] @@ -134,7 +135,7 @@ public function it_only_calls_registered_closures_that_are_referenced() #[Test] public function it_throws_for_invalid_registered_handles() { - foreach (['owner', 'shop owner.name', 'shop.na!me', 'site.custom', 'globals.company.name'] as $handle) { + foreach (['owner', 'shop owner.name', 'shop.na!me', 'site.custom', 'form.custom', 'globals.company.name'] as $handle) { try { Form::registerMention($handle, 'Label', fn () => 'x'); $this->fail("Handle [{$handle}] should have been rejected."); @@ -225,6 +226,31 @@ public function it_resolves_site_and_registered_values() ], $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_registered_closures() + { + $received = null; + Form::registerMention('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() { From ff2fa04395f93ee864fbe98545dfbb8b1aede0d4 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 14 Oct 2026 19:00:23 -0400 Subject: [PATCH 6/6] Only print filenames for non-stored form upload mentions Co-Authored-By: Claude Opus 5.5 --- src/Forms/SubmissionMentions.php | 19 +++++++++++- tests/Forms/SubmissionMentionsTest.php | 40 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Forms/SubmissionMentions.php b/src/Forms/SubmissionMentions.php index da0eac9753e..27639b63488 100644 --- a/src/Forms/SubmissionMentions.php +++ b/src/Forms/SubmissionMentions.php @@ -11,8 +11,10 @@ use Statamic\Fields\LabeledValue; use Statamic\Fields\Value; use Statamic\Fieldtypes\AutocompleteEditor\Mentions; +use Statamic\Fieldtypes\FormUpload; use Statamic\Fieldtypes\Markdown; use Statamic\Fieldtypes\Relationship; +use Statamic\Support\Arr; use Stringable; use function Statamic\trans as __; @@ -87,7 +89,7 @@ public static function values(Submission $submission, array $handles, ?array $su continue; } else { $submissionData ??= $submission->toAugmentedArray(); - $value = $submissionData[$handle] ?? null; + $value = static::formFieldValue($submission, $handle, $submissionData[$handle] ?? null); } $values[$handle] = static::mentionValue($value); @@ -139,6 +141,21 @@ public static function mentionValue(mixed $value): string return ''; } + 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; diff --git a/tests/Forms/SubmissionMentionsTest.php b/tests/Forms/SubmissionMentionsTest.php index 1a011e45602..6b863e71496 100644 --- a/tests/Forms/SubmissionMentionsTest.php +++ b/tests/Forms/SubmissionMentionsTest.php @@ -344,6 +344,46 @@ public function __toString() })); } + #[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([