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(); + } +}