Apply Mentions everywhere (#595)

* variables and mentions

* fix lint

* add missing changes

* fix tests

* update quilly, fix bugs

* fix lint

* apply fixes

* apply fixes

* Fix MentionParser

* Apply Mentions everywhere

* Fix MentionParserTest

* Small refactoring

* Fixing quill import issues

* Polished email integration, added customer sender mail

* Add missing changes

* improve migration command

---------

Co-authored-by: Frank <csskfaves@gmail.com>
Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala
2024-10-22 14:04:29 +05:30
committed by GitHub
parent 2fdf2a439b
commit dad5c825b1
50 changed files with 1903 additions and 874 deletions

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegration;
use Illuminate\Console\Command;
class EmailNotificationMigration extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'forms:email-notification-migration';
/**
* The console command description.
*
* @var string
*/
protected $description = 'One Time Only -- Migrate Email & Submission Notifications to new Email Integration';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
if (app()->environment('production')) {
if (!$this->confirm('Are you sure you want to run this migration in production?')) {
$this->info('Migration aborted.');
return 0;
}
}
$query = FormIntegration::whereIn('integration_id', ['email', 'submission_confirmation'])
->whereHas('form');
$totalCount = $query->count();
$progressBar = $this->output->createProgressBar($totalCount);
$progressBar->start();
$query->with('form')->chunk(100, function ($integrations) use ($progressBar) {
foreach ($integrations as $integration) {
try {
$this->updateIntegration($integration);
} catch (\Exception $e) {
$this->error('Error updating integration ' . $integration->id . '. Error: ' . $e->getMessage());
ray($e);
}
$progressBar->advance();
}
});
$progressBar->finish();
$this->newLine();
$this->line('Migration Done');
}
public function updateIntegration(FormIntegration $integration)
{
if (!$integration->form) {
return;
}
$existingData = $integration->data;
if ($integration->integration_id === 'email') {
$integration->data = [
'send_to' => $existingData->notification_emails ?? null,
'sender_name' => 'OpnForm',
'subject' => 'New form submission',
'email_content' => 'Hello there 👋 <br>New form submission received.',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => $existingData->notification_reply_to ?? null
];
} elseif ($integration->integration_id === 'submission_confirmation') {
$integration->integration_id = 'email';
$integration->data = [
'send_to' => $this->getMentionHtml($integration->form),
'sender_name' => $existingData->notification_sender,
'subject' => $existingData->notification_subject,
'email_content' => $existingData->notification_body,
'include_submission_data' => $existingData->notifications_include_submission,
'include_hidden_fields_submission_data' => false,
'reply_to' => $existingData->confirmation_reply_to ?? null
];
}
return $integration->save();
}
private function getMentionHtml(Form $form)
{
$emailField = $this->getRespondentEmail($form);
return $emailField ? '<span mention-field-id="' . $emailField['id'] . '" mention-field-name="' . $emailField['name'] . '" mention-fallback="" contenteditable="false" mention="true">' . $emailField['name'] . '</span>' : '';
}
private function getRespondentEmail(Form $form)
{
$emailFields = collect($form->properties)->filter(function ($field) {
$hidden = $field['hidden'] ?? false;
return !$hidden && $field['type'] == 'email';
});
return $emailFields->count() > 0 ? $emailFields->first() : null;
}
}

View File

@@ -9,6 +9,7 @@ use App\Http\Resources\FormSubmissionResource;
use App\Jobs\Form\StoreFormSubmissionJob;
use App\Models\Forms\Form;
use App\Models\Forms\FormSubmission;
use App\Open\MentionParser;
use App\Service\Forms\FormCleaner;
use App\Service\WorkspaceHelper;
use Illuminate\Http\Request;
@@ -105,13 +106,28 @@ class PublicFormController extends Controller
return $this->success(array_merge([
'message' => 'Form submission saved.',
'submission_id' => $submissionId,
'is_first_submission' => $isFirstSubmission
], $request->form->is_pro && $request->form->redirect_url ? [
'is_first_submission' => $isFirstSubmission,
], $this->getRedirectData($request->form, $submissionData)));
}
private function getRedirectData($form, $submissionData)
{
$formattedData = collect($submissionData)->map(function ($value, $key) {
return ['id' => $key, 'value' => $value];
})->values()->all();
$redirectUrl = ($form->redirect_url) ? (new MentionParser($form->redirect_url, $formattedData))->parse() : null;
if ($redirectUrl && !filter_var($redirectUrl, FILTER_VALIDATE_URL)) {
$redirectUrl = null;
}
return $form->is_pro && $redirectUrl ? [
'redirect' => true,
'redirect_url' => $request->form->redirect_url,
'redirect_url' => $redirectUrl,
] : [
'redirect' => false,
]));
];
}
public function fetchSubmission(Request $request, string $slug, string $submissionId)

View File

@@ -53,7 +53,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
're_fillable' => 'boolean',
're_fill_button_text' => 'string|min:1|max:50',
'submitted_text' => 'string|max:2000',
'redirect_url' => 'nullable|active_url|max:255',
'redirect_url' => 'nullable|max:255',
'database_fields_update' => 'nullable|array',
'max_submissions_count' => 'integer|nullable|min:1',
'max_submissions_reached_text' => 'string|nullable',

View File

@@ -2,6 +2,7 @@
namespace App\Integrations\Handlers;
use App\Open\MentionParser;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Arr;
use Vinkla\Hashids\Facades\Hashids;
@@ -32,6 +33,9 @@ class DiscordIntegration extends AbstractIntegrationHandler
protected function getWebhookData(): array
{
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
$formattedData = $formatter->getFieldsWithValue();
$settings = (array) $this->integrationData ?? [];
$externalLinks = [];
if (Arr::get($settings, 'link_open_form', true)) {
@@ -50,8 +54,7 @@ class DiscordIntegration extends AbstractIntegrationHandler
$blocks = [];
if (Arr::get($settings, 'include_submission_data', true)) {
$submissionString = '';
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
foreach ($formatter->getFieldsWithValue() as $field) {
foreach ($formattedData as $field) {
$tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value'];
$submissionString .= '**' . ucfirst($field['name']) . '**: ' . $tmpVal . "\n";
}
@@ -80,8 +83,9 @@ class DiscordIntegration extends AbstractIntegrationHandler
];
}
$message = Arr::get($settings, 'message', 'New form submission');
return [
'content' => 'New submission for your form **' . $this->form->title . '**',
'content' => (new MentionParser($message, $formattedData))->parse(),
'tts' => false,
'username' => config('app.name'),
'avatar_url' => asset('img/logo.png'),

View File

@@ -2,24 +2,51 @@
namespace App\Integrations\Handlers;
use App\Rules\OneEmailPerLine;
use App\Notifications\Forms\FormEmailNotification;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use App\Notifications\Forms\FormSubmissionNotification;
use App\Open\MentionParser;
use App\Service\Forms\FormSubmissionFormatter;
class EmailIntegration extends AbstractEmailIntegrationHandler
{
public const RISKY_USERS_LIMIT = 120;
public static function getValidationRules(): array
{
return [
'notification_emails' => ['required', new OneEmailPerLine()],
'notification_reply_to' => 'email|nullable',
'send_to' => 'required',
'sender_name' => 'required',
'sender_email' => 'email|nullable',
'subject' => 'required',
'email_content' => 'required',
'include_submission_data' => 'boolean',
'include_hidden_fields_submission_data' => ['nullable', 'boolean'],
'reply_to' => 'nullable',
];
}
protected function shouldRun(): bool
{
return $this->integrationData->notification_emails && parent::shouldRun();
return $this->integrationData->send_to && parent::shouldRun() && !$this->riskLimitReached();
}
// To avoid phishing abuse we limit this feature for risky users
private function riskLimitReached(): bool
{
// This is a per-workspace limit for risky workspaces
if ($this->form->workspace->is_risky) {
if ($this->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) {
Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [
'form_id' => $this->form->id,
'workspace_id' => $this->form->workspace->id,
]);
return true;
}
}
return false;
}
public function handle(): void
@@ -28,19 +55,27 @@ class EmailIntegration extends AbstractEmailIntegrationHandler
return;
}
$subscribers = collect(preg_split("/\r\n|\n|\r/", $this->integrationData->notification_emails))
if ($this->form->is_pro) { // For Send to field Mentions are Pro feature
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
$parser = new MentionParser($this->integrationData->send_to, $formatter->getFieldsWithValue());
$sendTo = $parser->parse();
} else {
$sendTo = $this->integrationData->send_to;
}
$recipients = collect(preg_split("/\r\n|\n|\r/", $sendTo))
->filter(function ($email) {
return filter_var($email, FILTER_VALIDATE_EMAIL);
});
Log::debug('Sending email notification', [
'recipients' => $subscribers->toArray(),
'recipients' => $recipients->toArray(),
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
'mailer' => $this->mailer
]);
$subscribers->each(function ($subscriber) {
$recipients->each(function ($subscriber) {
Notification::route('mail', $subscriber)->notify(
new FormSubmissionNotification($this->event, $this->integrationData, $this->mailer)
new FormEmailNotification($this->event, $this->integrationData, $this->mailer)
);
});
}

View File

@@ -2,6 +2,7 @@
namespace App\Integrations\Handlers;
use App\Open\MentionParser;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Arr;
use Vinkla\Hashids\Facades\Hashids;
@@ -32,6 +33,9 @@ class SlackIntegration extends AbstractIntegrationHandler
protected function getWebhookData(): array
{
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
$formattedData = $formatter->getFieldsWithValue();
$settings = (array) $this->integrationData ?? [];
$externalLinks = [];
if (Arr::get($settings, 'link_open_form', true)) {
@@ -46,20 +50,20 @@ class SlackIntegration extends AbstractIntegrationHandler
$externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|✍️ ' . $this->form->editable_submissions_button_text . '>*';
}
$message = Arr::get($settings, 'message', 'New form submission');
$blocks = [
[
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
'text' => 'New submission for your form *' . $this->form->title . '*',
'text' => (new MentionParser($message, $formattedData))->parse(),
],
],
];
if (Arr::get($settings, 'include_submission_data', true)) {
$submissionString = '';
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly();
foreach ($formatter->getFieldsWithValue() as $field) {
foreach ($formattedData as $field) {
$tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value'];
$submissionString .= '>*' . ucfirst($field['name']) . '*: ' . $tmpVal . " \n";
}

View File

@@ -1,113 +0,0 @@
<?php
namespace App\Integrations\Handlers;
use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Stevebauman\Purify\Facades\Purify;
/**
* Sends a confirmation to form respondant that form was submitted
*/
class SubmissionConfirmationIntegration extends AbstractEmailIntegrationHandler
{
public const RISKY_USERS_LIMIT = 120;
public static function getValidationRules(): array
{
return [
'respondent_email' => [
'required',
'boolean',
function ($attribute, $value, $fail) {
if ($value !== true) {
$fail('Need at least 1 email field.');
}
},
],
'confirmation_reply_to' => 'email|nullable',
'notification_sender' => 'required',
'notification_subject' => 'required',
'notification_body' => 'required',
'notifications_include_submission' => 'boolean'
];
}
protected function shouldRun(): bool
{
return !(!$this->form->is_pro) && parent::shouldRun() && !$this->riskLimitReached();
}
public function handle(): void
{
if (!$this->shouldRun()) {
return;
}
$email = $this->getRespondentEmail();
if (!$email) {
return;
}
Log::info('Sending submission confirmation', [
'recipient' => $email,
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
'mailer' => $this->mailer
]);
Mail::mailer($this->mailer)->to($email)->send(new SubmissionConfirmationMail($this->event, $this->integrationData));
}
private function getRespondentEmail()
{
// Make sure we only have one email field in the form
$emailFields = collect($this->form->properties)->filter(function ($field) {
$hidden = $field['hidden'] ?? false;
return !$hidden && $field['type'] == 'email';
});
if ($emailFields->count() != 1) {
return null;
}
if (isset($this->submissionData[$emailFields->first()['id']])) {
$email = $this->submissionData[$emailFields->first()['id']];
if ($this->validateEmail($email)) {
return $email;
}
}
return null;
}
// To avoid phishing abuse we limit this feature for risky users
private function riskLimitReached(): bool
{
// This is a per-workspace limit for risky workspaces
if ($this->form->workspace->is_risky) {
if ($this->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) {
Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [
'form_id' => $this->form->id,
'workspace_id' => $this->form->workspace->id,
]);
return true;
}
}
return false;
}
public static function validateEmail($email): bool
{
return (bool)filter_var($email, FILTER_VALIDATE_EMAIL);
}
public static function formatData(array $data): array
{
return array_merge(parent::formatData($data), [
'notification_body' => Purify::clean($data['notification_body'] ?? ''),
]);
}
}

View File

@@ -1,75 +0,0 @@
<?php
namespace App\Mail\Forms;
use App\Events\Forms\FormSubmitted;
use App\Mail\OpenFormMail;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Vinkla\Hashids\Facades\Hashids;
class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
{
use Queueable;
use SerializesModels;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(private FormSubmitted $event, private $integrationData)
{
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$form = $this->event->form;
$formatter = (new FormSubmissionFormatter($form, $this->event->data))
->createLinks()
->outputStringsOnly()
->useSignedUrlForFiles();
return $this
->replyTo($this->getReplyToEmail($form->creator->email))
->from($this->getFromEmail(), $this->integrationData->notification_sender)
->subject($this->integrationData->notification_subject)
->markdown('mail.form.confirmation-submission-notification', [
'fields' => $formatter->getFieldsWithValue(),
'form' => $form,
'integrationData' => $this->integrationData,
'noBranding' => $form->no_branding,
'submission_id' => (isset($this->event->data['submission_id']) && $this->event->data['submission_id']) ? Hashids::encode($this->event->data['submission_id']) : null,
]);
}
private function getFromEmail()
{
if (config('app.self_hosted')) {
return config('mail.from.address');
}
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last();
}
private function getReplyToEmail($default)
{
$replyTo = $this->integrationData->confirmation_reply_to ?? null;
if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
return $replyTo;
}
return $default;
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Notifications\Forms;
use App\Events\Forms\FormSubmitted;
use App\Open\MentionParser;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
use Vinkla\Hashids\Facades\Hashids;
use Symfony\Component\Mime\Email;
class FormEmailNotification extends Notification implements ShouldQueue
{
use Queueable;
public FormSubmitted $event;
public string $mailer;
private array $formattedData;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(FormSubmitted $event, private $integrationData, string $mailer)
{
$this->event = $event;
$this->mailer = $mailer;
$this->formattedData = $this->formatSubmissionData();
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
return (new MailMessage())
->mailer($this->mailer)
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
->from($this->getFromEmail(), $this->getSenderName())
->subject($this->getSubject())
->withSymfonyMessage(function (Email $message) {
$this->addCustomHeaders($message);
})
->markdown('mail.form.email-notification', $this->getMailData());
}
private function formatSubmissionData(): array
{
$formatter = (new FormSubmissionFormatter($this->event->form, $this->event->data))
->createLinks()
->outputStringsOnly()
->useSignedUrlForFiles();
if ($this->integrationData->include_hidden_fields_submission_data ?? false) {
$formatter->showHiddenFields();
}
return $formatter->getFieldsWithValue();
}
private function getFromEmail(): string
{
if (
config('app.self_hosted')
&& isset($this->integrationData->sender_email)
&& $this->validateEmail($this->integrationData->sender_email)
) {
return $this->integrationData->sender_email;
}
return config('mail.from.address');
}
private function getSenderName(): string
{
return $this->integrationData->sender_name ?? config('app.name');
}
private function getReplyToEmail($default): string
{
$replyTo = $this->integrationData->reply_to ?? null;
if ($replyTo) {
$parsedReplyTo = $this->parseReplyTo($replyTo);
if ($parsedReplyTo && $this->validateEmail($parsedReplyTo)) {
return $parsedReplyTo;
}
}
return $this->getRespondentEmail() ?? $default;
}
private function parseReplyTo(string $replyTo): ?string
{
$parser = new MentionParser($replyTo, $this->formattedData);
return $parser->parse();
}
private function getSubject(): string
{
$defaultSubject = 'New form submission';
$parser = new MentionParser($this->integrationData->subject ?? $defaultSubject, $this->formattedData);
return $parser->parse();
}
private function addCustomHeaders(Email $message): void
{
$formId = $this->event->form->id;
$submissionId = $this->event->data['submission_id'] ?? 'unknown';
$domain = Str::after(config('app.url'), '://');
$uniquePart = substr(md5($formId . $submissionId), 0, 8);
$messageId = "form-{$formId}-{$uniquePart}@{$domain}";
$references = "form-{$formId}@{$domain}";
$message->getHeaders()->remove('Message-ID');
$message->getHeaders()->addIdHeader('Message-ID', $messageId);
$message->getHeaders()->addTextHeader('References', $references);
}
private function getMailData(): array
{
return [
'emailContent' => $this->getEmailContent(),
'fields' => $this->formattedData,
'form' => $this->event->form,
'integrationData' => $this->integrationData,
'noBranding' => $this->event->form->no_branding,
'submission_id' => $this->getEncodedSubmissionId(),
];
}
private function getEmailContent(): string
{
$parser = new MentionParser($this->integrationData->email_content ?? '', $this->formattedData);
return $parser->parse();
}
private function getEncodedSubmissionId(): ?string
{
$submissionId = $this->event->data['submission_id'] ?? null;
return $submissionId ? Hashids::encode($submissionId) : null;
}
private function getRespondentEmail(): ?string
{
$emailFields = ['email', 'e-mail', 'mail'];
foreach ($this->formattedData as $field => $value) {
if (in_array(strtolower($field), $emailFields) && $this->validateEmail($value)) {
return $value;
}
}
// If no email field found, search for any field containing a valid email
foreach ($this->formattedData as $value) {
if ($this->validateEmail($value)) {
return $value;
}
}
return null;
}
public static function validateEmail($email): bool
{
return (bool)filter_var($email, FILTER_VALIDATE_EMAIL);
}
}

View File

@@ -1,113 +0,0 @@
<?php
namespace App\Notifications\Forms;
use App\Events\Forms\FormSubmitted;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class FormSubmissionNotification extends Notification implements ShouldQueue
{
use Queueable;
public FormSubmitted $event;
private $mailer;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(FormSubmitted $event, private $integrationData, string $mailer)
{
$this->event = $event;
$this->mailer = $mailer;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
$formatter = (new FormSubmissionFormatter($this->event->form, $this->event->data))
->showHiddenFields()
->createLinks()
->outputStringsOnly()
->useSignedUrlForFiles();
return (new MailMessage())
->mailer($this->mailer)
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
->from($this->getFromEmail(), config('app.name'))
->subject('New form submission for "' . $this->event->form->title . '"')
->markdown('mail.form.submission-notification', [
'fields' => $formatter->getFieldsWithValue(),
'form' => $this->event->form,
]);
}
private function getFromEmail()
{
if (config('app.self_hosted')) {
return config('mail.from.address');
}
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last();
}
private function getReplyToEmail($default)
{
$replyTo = $this->integrationData->notification_reply_to ?? null;
if ($replyTo && $this->validateEmail($replyTo)) {
return $replyTo;
}
return $this->getRespondentEmail() ?? $default;
}
private function getRespondentEmail()
{
// Make sure we only have one email field in the form
$emailFields = collect($this->event->form->properties)->filter(function ($field) {
$hidden = $field['hidden'] ?? false;
return !$hidden && $field['type'] == 'email';
});
if ($emailFields->count() != 1) {
return null;
}
if (isset($this->event->data[$emailFields->first()['id']])) {
$email = $this->event->data[$emailFields->first()['id']];
if ($this->validateEmail($email)) {
return $email;
}
}
return null;
}
public static function validateEmail($email): bool
{
return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Open;
use DOMDocument;
use DOMXPath;
class MentionParser
{
private $content;
private $data;
public function __construct($content, $data)
{
$this->content = $content;
$this->data = $data;
}
public function parse()
{
$doc = new DOMDocument();
// Disable libxml errors and use internal errors
$internalErrors = libxml_use_internal_errors(true);
// Wrap the content in a root element to ensure it's valid XML
$wrappedContent = '<root>' . $this->content . '</root>';
// Load HTML, using UTF-8 encoding
$doc->loadHTML(mb_convert_encoding($wrappedContent, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
// Restore libxml error handling
libxml_use_internal_errors($internalErrors);
$xpath = new DOMXPath($doc);
$mentionElements = $xpath->query("//span[@mention]");
foreach ($mentionElements as $element) {
$fieldId = $element->getAttribute('mention-field-id');
$fallback = $element->getAttribute('mention-fallback');
$value = $this->getData($fieldId);
if ($value !== null) {
$textNode = $doc->createTextNode(is_array($value) ? implode(', ', $value) : $value);
$element->parentNode->replaceChild($textNode, $element);
} elseif ($fallback) {
$textNode = $doc->createTextNode($fallback);
$element->parentNode->replaceChild($textNode, $element);
} else {
$element->parentNode->removeChild($element);
}
}
// Extract and return the processed HTML content
$result = $doc->saveHTML($doc->getElementsByTagName('root')->item(0));
// Remove the root tags we added
$result = preg_replace('/<\/?root>/', '', $result);
// Trim whitespace and convert HTML entities back to UTF-8 characters
$result = trim(html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return $result;
}
private function replaceMentions()
{
$pattern = '/<span[^>]*mention-field-id="([^"]*)"[^>]*mention-fallback="([^"]*)"[^>]*>.*?<\/span>/';
return preg_replace_callback($pattern, function ($matches) {
$fieldId = $matches[1];
$fallback = $matches[2];
$value = $this->getData($fieldId);
if ($value !== null) {
if (is_array($value)) {
return implode(' ', array_map(function ($v) {
return $v;
}, $value));
}
return $value;
} elseif ($fallback) {
return $fallback;
}
return '';
}, $this->content);
}
private function getData($fieldId)
{
$value = collect($this->data)->firstWhere('id', $fieldId)['value'] ?? null;
if (is_object($value)) {
return (array) $value;
}
return $value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Service\HtmlPurifier;
use HTMLPurifier_HTMLDefinition;
use Stevebauman\Purify\Definitions\Definition;
use Stevebauman\Purify\Definitions\Html5Definition;
class OpenFormsHtmlDefinition implements Definition
{
public static function apply(HTMLPurifier_HTMLDefinition $definition)
{
Html5Definition::apply($definition);
$definition->addAttribute('span', 'mention-field-id', 'Text');
$definition->addAttribute('span', 'mention-field-name', 'Text');
$definition->addAttribute('span', 'mention-fallback', 'Text');
$definition->addAttribute('span', 'mention', 'Bool');
$definition->addAttribute('span', 'contenteditable', 'Bool');
}
}

View File

@@ -1,6 +1,6 @@
<?php
use Stevebauman\Purify\Definitions\Html5Definition;
use App\Service\HtmlPurifier\OpenFormsHtmlDefinition;
return [
@@ -40,7 +40,7 @@ return [
'configs' => [
'default' => [
'HTML.Allowed' => 'h1,h2,b,u,strong,i,em,a[href|title],ul,ol,li,p,br,span,*[style]',
'HTML.Allowed' => 'h1,h2,b,u,strong,i,em,a[href|title],ul,ol,li,p,br,span[mention|mention-field-id|mention-field-name|mention-fallback],*[style]',
'HTML.ForbiddenElements' => '',
'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,text-decoration,color,text-align',
@@ -86,7 +86,7 @@ return [
|
*/
'definitions' => Html5Definition::class,
'definitions' => OpenFormsHtmlDefinition::class,
/*
|--------------------------------------------------------------------------

View File

@@ -7,13 +7,6 @@
"is_pro": false,
"crisp_help_page_slug": "can-i-receive-notifications-on-form-submissions-134svqv"
},
"submission_confirmation": {
"name": "Submission Confirmation",
"icon": "heroicons:paper-airplane-20-solid",
"section_name": "Notifications",
"file_name": "SubmissionConfirmationIntegration",
"is_pro": true
},
"slack": {
"name": "Slack Notification",
"icon": "mdi:slack",

View File

@@ -1,34 +0,0 @@
@component('mail::message', ['noBranding' => $noBranding])
{!! $integrationData->notification_body !!}
@if($form->editable_submissions)
@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id])
{{($form->editable_submissions_button_text ?? 'Edit submission')}}
@endcomponent
@endif
@if($integrationData->notifications_include_submission)
As a reminder, here are your answers:
@foreach($fields as $field)
@if(isset($field['value']))
--------------------------------------------------------------------------------
**{{$field['name']}}**
@if($field['type'] == 'files')
<br />
@foreach($field['email_data'] as $link)
<a href="{{$link['signed_url']}}">{{$link['label']}}</a> <br />
@endforeach
@else
{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
@endif
@endif
@endforeach
@endif
<p style="text-align:center"><small>You are receiving this email because you answered the form: <a href="{{front_url("forms/".$form->slug)}}">"{{$form->title}}"</a>.</small></p>
@endcomponent

View File

@@ -0,0 +1,22 @@
@component('mail::message', ['noBranding' => $noBranding])
{!! $emailContent !!}
@if($form->editable_submissions)
@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id])
{{($form->editable_submissions_button_text ?? 'Edit submission')}}
@endcomponent
@endif
@if($integrationData->include_submission_data)
@foreach($fields as $field)
@if(isset($field['value']))
<p style="white-space: pre-wrap; border-top: 1px solid #9ca3af;">
<b>{{$field['name']}}</b>
{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
</p>
@endif
@endforeach
@endif
@endcomponent

View File

@@ -1,28 +0,0 @@
@component('mail::message')
Hello there 👋
Your form "{{$form->title}}" has a new submission.
@foreach($fields as $field)
@if(isset($field['value']))
--------------------------------------------------------------------------------
**{{$field['name']}}**
@if($field['type'] == 'files')
<br/>
@foreach($field['email_data'] as $link)
<a href="{{$link['signed_url']}}">{{$link['label']}}</a> <br/>
@endforeach
@else
@if($field['type'] == 'matrix')
{!! nl2br(e($field['value'])) !!}
@else
{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
@endif
@endif
@endif
@endforeach
@endcomponent

View File

@@ -1,144 +0,0 @@
<?php
use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Support\Facades\Mail;
it('creates confirmation emails with the submitted data', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
'respondent_email' => true,
'notifications_include_submission' => true,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Test subject',
'notification_body' => 'Test body',
]);
$formData = [
collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
})['id'] => 'test@test.com',
];
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new SubmissionConfirmationMail($event, $integrationData);
$mailable->assertSeeInHtml('Test body')
->assertSeeInHtml('As a reminder, here are your answers:')
->assertSeeInHtml('You are receiving this email because you answered the form:');
});
it('creates confirmation emails without the submitted data', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
'respondent_email' => true,
'notifications_include_submission' => false,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Test subject',
'notification_body' => 'Test body',
]);
$formData = [
collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
})['id'] => 'test@test.com',
];
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new SubmissionConfirmationMail($event, $integrationData);
$mailable->assertSeeInHtml('Test body')
->assertDontSeeInHtml('As a reminder, here are your answers:')
->assertSeeInHtml('You are receiving this email because you answered the form:');
});
it('sends a confirmation email if needed', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$this->createFormIntegration('submission_confirmation', $form->id, [
'respondent_email' => true,
'notifications_include_submission' => true,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Test subject',
'notification_body' => 'Test body',
]);
$emailProperty = collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
});
$formData = [
$emailProperty['id'] => 'test@test.com',
];
Mail::fake();
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
Mail::assertQueued(
SubmissionConfirmationMail::class,
function (SubmissionConfirmationMail $mail) {
return $mail->hasTo('test@test.com');
}
);
});
it('does not send a confirmation email if not needed', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$emailProperty = collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
});
$formData = [
$emailProperty['id'] => 'test@test.com',
];
Mail::fake();
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
Mail::assertNotQueued(
SubmissionConfirmationMail::class,
function (SubmissionConfirmationMail $mail) {
return $mail->hasTo('test@test.com');
}
);
});
it('does send a confirmation email even when reply to is broken', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
'respondent_email' => true,
'notifications_include_submission' => true,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Test subject',
'notification_body' => 'Test body',
'confirmation_reply_to' => ''
]);
$emailProperty = collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
});
$formData = [
$emailProperty['id'] => 'test@test.com',
];
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new SubmissionConfirmationMail($event, $integrationData);
$mailable->assertSeeInHtml('Test body')
->assertSeeInHtml('As a reminder, here are your answers:')
->assertSeeInHtml('You are receiving this email because you answered the form:')
->assertHasReplyTo($user->email); // Even though reply to is wrong, it should use the user's email
});

View File

@@ -1,7 +1,8 @@
<?php
use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Support\Facades\Mail;
use App\Notifications\Forms\FormEmailNotification;
use Tests\Helpers\FormSubmissionDataFactory;
use Illuminate\Notifications\AnonymousNotifiable;
it('can not save custom SMTP settings if not pro user', function () {
$user = $this->actingAsUser();
@@ -15,7 +16,7 @@ it('can not save custom SMTP settings if not pro user', function () {
])->assertStatus(403);
});
it('creates confirmation emails with custom SMTP settings', function () {
it('send email with custom SMTP settings', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
@@ -28,21 +29,19 @@ it('creates confirmation emails with custom SMTP settings', function () {
'password' => 'custom_password',
])->assertSuccessful();
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
'respondent_email' => true,
'notifications_include_submission' => true,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Custom SMTP Test',
'notification_body' => 'This email was sent using custom SMTP settings',
$integrationData = $this->createFormIntegration('email', $form->id, [
'send_to' => 'test@test.com',
'sender_name' => 'OpnForm',
'subject' => 'New form submission',
'email_content' => 'Hello there 👋 <br>New form submission received.',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => 'reply@example.com',
]);
$formData = [
collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
})['id'] => 'test@test.com',
];
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
Mail::fake();
Notification::fake();
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
@@ -51,10 +50,12 @@ it('creates confirmation emails with custom SMTP settings', function () {
'message' => 'Form submission saved.',
]);
Mail::assertQueued(
SubmissionConfirmationMail::class,
function (SubmissionConfirmationMail $mail) {
return $mail->hasTo('test@test.com') && $mail->mailer === 'custom_smtp';
Notification::assertSentTo(
new AnonymousNotifiable(),
FormEmailNotification::class,
function (FormEmailNotification $notification, $channels, $notifiable) {
return $notifiable->routes['mail'] === 'test@test.com' &&
$notification->mailer === 'custom_smtp';
}
);
});

View File

@@ -0,0 +1,165 @@
<?php
use App\Notifications\Forms\FormEmailNotification;
use Tests\Helpers\FormSubmissionDataFactory;
use Illuminate\Notifications\AnonymousNotifiable;
it('send email with the submitted data', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$integrationData = $this->createFormIntegration('email', $form->id, [
'send_to' => 'test@test.com',
'sender_name' => 'OpnForm',
'subject' => 'New form submission',
'email_content' => 'Hello there 👋 <br>Test body',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => 'reply@example.com',
]);
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new FormEmailNotification($event, $integrationData, 'mail');
$notifiable = new AnonymousNotifiable();
$notifiable->route('mail', 'test@test.com');
$renderedMail = $mailable->toMail($notifiable);
expect($renderedMail->subject)->toBe('New form submission');
expect(trim($renderedMail->render()))->toContain('Test body');
});
it('sends a email if needed', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$emailProperty = collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
});
$this->createFormIntegration('email', $form->id, [
'send_to' => '<span mention-field-id="' . $emailProperty['id'] . '" mention-field-name="' . $emailProperty['name'] . '" mention-fallback="" contenteditable="false" mention="true">' . $emailProperty['name'] . '</span>',
'sender_name' => 'OpnForm',
'subject' => 'New form submission',
'email_content' => 'Hello there 👋 <br>New form submission received.',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => 'reply@example.com',
]);
$formData = [
$emailProperty['id'] => 'test@test.com',
];
Notification::fake();
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
Notification::assertSentTo(
new AnonymousNotifiable(),
FormEmailNotification::class,
function (FormEmailNotification $notification, $channels, $notifiable) {
return $notifiable->routes['mail'] === 'test@test.com';
}
);
});
it('does not send a email if not needed', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$emailProperty = collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
});
$formData = [
$emailProperty['id'] => 'test@test.com',
];
Notification::fake();
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
Notification::assertNotSentTo(
new AnonymousNotifiable(),
FormEmailNotification::class,
function (FormEmailNotification $notification, $channels, $notifiable) {
return $notifiable->routes['mail'] === 'test@test.com';
}
);
});
it('uses custom sender email in self-hosted mode', function () {
config(['app.self_hosted' => true]);
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$customSenderEmail = 'custom@example.com';
$integrationData = $this->createFormIntegration('email', $form->id, [
'send_to' => 'test@test.com',
'sender_name' => 'Custom Sender',
'sender_email' => $customSenderEmail,
'subject' => 'Custom Subject',
'email_content' => 'Custom content',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => 'reply@example.com',
]);
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new FormEmailNotification($event, $integrationData, 'mail');
$notifiable = new AnonymousNotifiable();
$notifiable->route('mail', 'test@test.com');
$renderedMail = $mailable->toMail($notifiable);
expect($renderedMail->from[0])->toBe($customSenderEmail);
expect($renderedMail->from[1])->toBe('Custom Sender');
expect($renderedMail->subject)->toBe('Custom Subject');
expect(trim($renderedMail->render()))->toContain('Custom content');
});
it('does not use custom sender email in non-self-hosted mode', function () {
config(['app.self_hosted' => false]);
config(['mail.from.address' => 'default@example.com']);
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$customSenderEmail = 'custom@example.com';
$integrationData = $this->createFormIntegration('email', $form->id, [
'send_to' => 'test@test.com',
'sender_name' => 'Custom Sender',
'sender_email' => $customSenderEmail,
'subject' => 'Custom Subject',
'email_content' => 'Custom content',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => 'reply@example.com',
]);
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
$event = new \App\Events\Forms\FormSubmitted($form, $formData);
$mailable = new FormEmailNotification($event, $integrationData, 'mail');
$notifiable = new AnonymousNotifiable();
$notifiable->route('mail', 'test@test.com');
$renderedMail = $mailable->toMail($notifiable);
expect($renderedMail->from[0])->toBe('default@example.com');
expect($renderedMail->from[1])->toBe('Custom Sender');
expect($renderedMail->subject)->toBe('Custom Subject');
expect(trim($renderedMail->render()))->toContain('Custom content');
});

View File

@@ -10,8 +10,13 @@ it('can fetch form integration events', function () {
'integration_id' => 'email',
'logic' => null,
'settings' => [
'notification_emails' => 'test@test.com',
'notification_reply_to' => null
'send_to' => 'test@test.com',
'sender_name' => 'OpnForm',
'subject' => 'New form submission',
'email_content' => 'Hello there 👋 <br>New form submission received.',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => null
]
];

View File

@@ -10,8 +10,13 @@ it('can CRUD form integration', function () {
'integration_id' => 'email',
'logic' => null,
'settings' => [
'notification_emails' => 'test@test.com',
'notification_reply_to' => null
'send_to' => 'test@test.com',
'sender_name' => 'OpnForm',
'subject' => 'New form submission',
'email_content' => 'Hello there 👋 <br>New form submission received.',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => null
]
];

View File

@@ -0,0 +1,80 @@
<?php
namespace Tests\Unit;
use App\Console\Commands\EmailNotificationMigration;
use App\Models\Integration\FormIntegration;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
$this->command = new EmailNotificationMigration();
});
it('updates email integration correctly', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$integration = FormIntegration::create([
'integration_id' => 'email',
'form_id' => $form->id,
'status' => FormIntegration::STATUS_ACTIVE,
'data' => [
'notification_emails' => 'test@example.com',
'notification_reply_to' => 'reply@example.com',
],
]);
$this->command->updateIntegration($integration);
expect($integration->fresh())
->integration_id->toBe('email')
->data->toMatchArray([
'send_to' => 'test@example.com',
'sender_name' => 'OpnForm',
'subject' => 'New form submission',
'email_content' => 'Hello there 👋 <br>New form submission received.',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => 'reply@example.com',
]);
});
it('updates submission confirmation integration correctly', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$emailProperty = collect($form->properties)->filter(function ($property) {
return $property['type'] == 'email';
})->first();
$integration = FormIntegration::create([
'integration_id' => 'submission_confirmation',
'form_id' => $form->id,
'status' => FormIntegration::STATUS_ACTIVE,
'data' => [
'notification_sender' => 'Sender Name',
'notification_subject' => 'Thank you for your submission',
'notification_body' => 'We received your submission.',
'notifications_include_submission' => true,
'confirmation_reply_to' => 'reply@example.com',
],
]);
$this->command->updateIntegration($integration);
expect($integration->fresh())
->integration_id->toBe('email')
->data->toMatchArray([
'send_to' => '<span mention-field-id="' . $emailProperty['id'] . '" mention-field-name="' . $emailProperty['name'] . '" mention-fallback="" contenteditable="false" mention="true">' . $emailProperty['name'] . '</span>',
'sender_name' => 'Sender Name',
'subject' => 'Thank you for your submission',
'email_content' => 'We received your submission.',
'include_submission_data' => true,
'include_hidden_fields_submission_data' => false,
'reply_to' => 'reply@example.com',
]);
});

View File

@@ -0,0 +1,86 @@
<?php
use App\Open\MentionParser;
test('it replaces mention elements with their corresponding values', function () {
$content = '<p>Hello <span mention mention-field-id="123">Placeholder</span></p>';
$data = [['id' => '123', 'value' => 'World']];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Hello World</p>');
});
test('it handles multiple mentions', function () {
$content = '<p><span mention mention-field-id="123">Name</span> is <span mention mention-field-id="456">Age</span> years old</p>';
$data = [
['id' => '123', 'value' => 'John'],
['id' => '456', 'value' => 30],
];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>John is 30 years old</p>');
});
test('it uses fallback when value is not found', function () {
$content = '<p>Hello <span mention mention-field-id="123" mention-fallback="Friend">Placeholder</span></p>';
$data = [];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Hello Friend</p>');
});
test('it removes mention element when no value and no fallback', function () {
$content = '<p>Hello <span mention mention-field-id="123">Placeholder</span></p>';
$data = [];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Hello </p>');
});
test('it handles array values', function () {
$content = '<p>Tags: <span mention mention-field-id="123">Placeholder</span></p>';
$data = [['id' => '123', 'value' => ['PHP', 'Laravel', 'Testing']]];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Tags: PHP, Laravel, Testing</p>');
});
test('it preserves HTML structure', function () {
$content = '<div><p>Hello <span mention mention-field-id="123">Placeholder</span></p><p>How are you?</p></div>';
$data = [['id' => '123', 'value' => 'World']];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<div><p>Hello World</p><p>How are you?</p></div>');
});
test('it handles UTF-8 characters', function () {
$content = '<p>こんにちは <span mention mention-field-id="123">Placeholder</span></p>';
$data = [['id' => '123', 'value' => '世界']];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>こんにちは 世界</p>');
});
test('it handles content without surrounding paragraph tags', function () {
$content = 'some text <span contenteditable="false" mention="" mention-field-id="123" mention-field-name="Post excerpt" mention-fallback="">Post excerpt</span> dewde';
$data = [['id' => '123', 'value' => 'replaced text']];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('some text replaced text dewde');
});