diff --git a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php b/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php index 8b40fe4a6..a556e13c5 100644 --- a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php +++ b/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php @@ -26,7 +26,7 @@ function ($attribute, $value, $fail) { } }, ], - 'confirmation_reply_to' => 'email|nullable', + 'confirmation_reply_to' => 'nullable', 'notification_sender' => 'required', 'notification_subject' => 'required', 'notification_body' => 'required', @@ -107,6 +107,7 @@ public static function validateEmail($email): bool public static function formatData(array $data): array { return array_merge(parent::formatData($data), [ + 'notification_subject' => Purify::clean($data['notification_subject'] ?? ''), 'notification_body' => Purify::clean($data['notification_body'] ?? ''), ]); } diff --git a/api/app/Mail/Forms/SubmissionConfirmationMail.php b/api/app/Mail/Forms/SubmissionConfirmationMail.php index b50bb9c1e..ab51190a0 100644 --- a/api/app/Mail/Forms/SubmissionConfirmationMail.php +++ b/api/app/Mail/Forms/SubmissionConfirmationMail.php @@ -4,6 +4,7 @@ use App\Events\Forms\FormSubmitted; use App\Mail\OpenFormMail; +use App\Open\MentionParser; use App\Service\Forms\FormSubmissionFormatter; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -16,6 +17,8 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue use Queueable; use SerializesModels; + private $formattedData; + /** * Create a new message instance. * @@ -23,6 +26,12 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue */ public function __construct(private FormSubmitted $event, private $integrationData) { + $formatter = (new FormSubmissionFormatter($event->form, $event->data)) + ->createLinks() + ->outputStringsOnly() + ->useSignedUrlForFiles(); + + $this->formattedData = $formatter->getFieldsWithValue(); } /** @@ -34,17 +43,14 @@ 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) + ->subject($this->getSubject()) ->markdown('mail.form.confirmation-submission-notification', [ - 'fields' => $formatter->getFieldsWithValue(), + 'notificationBody' => $this->getNotificationBody(), + 'fields' => $this->formattedData, 'form' => $form, 'integrationData' => $this->integrationData, 'noBranding' => $form->no_branding, @@ -67,9 +73,25 @@ private function getReplyToEmail($default) { $replyTo = $this->integrationData->confirmation_reply_to ?? null; - if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) { - return $replyTo; + if ($replyTo) { + $parser = new MentionParser($replyTo, $this->formattedData); + $parsedReplyTo = $parser->parse(); + if ($parsedReplyTo && filter_var($parsedReplyTo, FILTER_VALIDATE_EMAIL)) { + return $parsedReplyTo; + } } return $default; } + + private function getSubject() + { + $parser = new MentionParser($this->integrationData->notification_subject, $this->formattedData); + return $parser->parse(); + } + + private function getNotificationBody() + { + $parser = new MentionParser($this->integrationData->notification_body, $this->formattedData); + return $parser->parse(); + } } diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php new file mode 100644 index 000000000..01c8e3c80 --- /dev/null +++ b/api/app/Open/MentionParser.php @@ -0,0 +1,97 @@ +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 = '' . $this->content . ''; + + // 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 = '/]*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; + } +} diff --git a/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php b/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php new file mode 100644 index 000000000..ba9fb807f --- /dev/null +++ b/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php @@ -0,0 +1,21 @@ +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'); + } +} diff --git a/api/config/purify.php b/api/config/purify.php index 24c8d1ce4..f440c058c 100644 --- a/api/config/purify.php +++ b/api/config/purify.php @@ -1,6 +1,6 @@ [ 'default' => [ - 'HTML.Allowed' => 'h1,h2,b,strong,i,em,a[href|title],ul,ol,li,p,br,span,*[style]', + 'HTML.Allowed' => 'h1,h2,b,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 @@ | */ - 'definitions' => Html5Definition::class, + 'definitions' => OpenFormsHtmlDefinition::class, /* |-------------------------------------------------------------------------- diff --git a/api/resources/views/mail/form/confirmation-submission-notification.blade.php b/api/resources/views/mail/form/confirmation-submission-notification.blade.php index 871998f30..271779fc0 100644 --- a/api/resources/views/mail/form/confirmation-submission-notification.blade.php +++ b/api/resources/views/mail/form/confirmation-submission-notification.blade.php @@ -1,6 +1,6 @@ @component('mail::message', ['noBranding' => $noBranding]) -{!! $integrationData->notification_body !!} +{!! $notificationBody !!} @if($form->editable_submissions) @component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id]) diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php new file mode 100644 index 000000000..908f0bc1b --- /dev/null +++ b/api/tests/Unit/Service/Forms/MentionParserTest.php @@ -0,0 +1,193 @@ +Hello Placeholder

'; + + $data = [['nf_id' => '123', 'value' => 'World']]; + + + + + $parser = new MentionParser($content, $data); + + $result = $parser->parse(); + + + + + expect($result)->toBe('

Hello World

'); + +}); + + + + +test('it handles multiple mentions', function () { + + $content = '

Name is Age years old

'; + + $data = [ + + ['nf_id' => '123', 'value' => 'John'], + + ['nf_id' => '456', 'value' => 30], + + ]; + + + + + $parser = new MentionParser($content, $data); + + $result = $parser->parse(); + + + + + expect($result)->toBe('

John is 30 years old

'); + +}); + + + + +test('it uses fallback when value is not found', function () { + + $content = '

Hello Placeholder

'; + + $data = []; + + + + + $parser = new MentionParser($content, $data); + + $result = $parser->parse(); + + + + + expect($result)->toBe('

Hello Friend

'); + +}); + + + + +test('it removes mention element when no value and no fallback', function () { + + $content = '

Hello Placeholder

'; + + $data = []; + + + + + $parser = new MentionParser($content, $data); + + $result = $parser->parse(); + + + + + expect($result)->toBe('

Hello

'); + +}); + + + + +test('it handles array values', function () { + + $content = '

Tags: Placeholder

'; + + $data = [['nf_id' => '123', 'value' => ['PHP', 'Laravel', 'Testing']]]; + + + + + $parser = new MentionParser($content, $data); + + $result = $parser->parse(); + + + + + expect($result)->toBe('

Tags: PHP, Laravel, Testing

'); + +}); + + + + +test('it preserves HTML structure', function () { + + $content = '

Hello Placeholder

How are you?

'; + + $data = [['nf_id' => '123', 'value' => 'World']]; + + + + + $parser = new MentionParser($content, $data); + + $result = $parser->parse(); + + + + + expect($result)->toBe('

Hello World

How are you?

'); + +}); + + + + +test('it handles UTF-8 characters', function () { + + $content = '

こんにちは Placeholder

'; + + $data = [['nf_id' => '123', 'value' => '世界']]; + + + + + $parser = new MentionParser($content, $data); + + $result = $parser->parse(); + + + + + expect($result)->toBe('

こんにちは 世界

'); + +}); + + + + +test('it handles content without surrounding paragraph tags', function () { + + $content = 'some text Post excerpt dewde'; + + $data = [['nf_id' => '123', 'value' => 'replaced text']]; + + + + + $parser = new MentionParser($content, $data); + + $result = $parser->parse(); + + + + + expect($result)->toBe('some text replaced text dewde'); + +}); diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue new file mode 100644 index 000000000..3dbb8369f --- /dev/null +++ b/client/components/forms/MentionInput.vue @@ -0,0 +1,190 @@ + + + + + \ No newline at end of file diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue index 26143045c..4f398b6f4 100644 --- a/client/components/forms/RichTextAreaInput.client.vue +++ b/client/components/forms/RichTextAreaInput.client.vue @@ -4,12 +4,8 @@ - + :style="{ + '--font-size': theme.default.fontSize + }" + > + + - +.ql-mention { + padding-top: 0px !important; +} +.ql-mention::after { + content: '@'; + font-size: 16px; +} +.rich-editor, .mention-input { + span[mention] { + @apply inline-flex items-center align-baseline leading-tight text-sm relative bg-blue-100 text-blue-800 border border-blue-200 rounded-md px-1 py-0.5 mx-0.5; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + \ No newline at end of file diff --git a/client/components/forms/TextBlock.vue b/client/components/forms/TextBlock.vue new file mode 100644 index 000000000..8a2c7c641 --- /dev/null +++ b/client/components/forms/TextBlock.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/client/components/forms/components/FormSubmissionFormatter.js b/client/components/forms/components/FormSubmissionFormatter.js new file mode 100644 index 000000000..ea7cab15e --- /dev/null +++ b/client/components/forms/components/FormSubmissionFormatter.js @@ -0,0 +1,105 @@ +import { format, parseISO } from 'date-fns' + +export class FormSubmissionFormatter { + constructor(form, formData) { + this.form = form + this.formData = formData + this.createLinks = false + this.outputStringsOnly = false + this.showGeneratedIds = false + this.datesIsoFormat = false + } + + setCreateLinks() { + this.createLinks = true + return this + } + + setShowGeneratedIds() { + this.showGeneratedIds = true + return this + } + + setOutputStringsOnly() { + this.outputStringsOnly = true + return this + } + + setUseIsoFormatForDates() { + this.datesIsoFormat = true + return this + } + + getFormattedData() { + const formattedData = {} + + this.form.properties.forEach(field => { + if (!this.formData[field.id] && !this.fieldGeneratesId(field)) { + return + } + + const value = this.formatFieldValue(field, this.formData[field.id]) + formattedData[field.id] = value + }) + + return formattedData + } + + formatFieldValue(field, value) { + switch (field.type) { + case 'url': + return this.createLinks ? `${value}` : value + case 'email': + return this.createLinks ? `${value}` : value + case 'checkbox': + return value ? 'Yes' : 'No' + case 'date': + return this.formatDateValue(field, value) + case 'people': + return this.formatPeopleValue(value) + case 'multi_select': + return this.outputStringsOnly ? value.join(', ') : value + case 'relation': + return this.formatRelationValue(value) + default: + return Array.isArray(value) && this.outputStringsOnly ? value.join(', ') : value + } + } + + formatDateValue(field, value) { + if (this.datesIsoFormat) { + return Array.isArray(value) + ? { start_date: value[0], end_date: value[1] || null } + : value + } + + const dateFormat = (field.date_format || 'dd/MM/yyyy') === 'dd/MM/yyyy' ? 'dd/MM/yyyy' : 'MM/dd/yyyy' + const timeFormat = field.with_time ? (field.time_format === 24 ? 'HH:mm' : 'h:mm a') : '' + const fullFormat = `${dateFormat}${timeFormat ? ' ' + timeFormat : ''}` + + if (Array.isArray(value)) { + const start = format(parseISO(value[0]), fullFormat) + const end = value[1] ? format(parseISO(value[1]), fullFormat) : null + return end ? `${start} - ${end}` : start + } + + return format(parseISO(value), fullFormat) + } + + formatPeopleValue(value) { + if (!value) return [] + const people = Array.isArray(value) ? value : [value] + return this.outputStringsOnly ? people.map(p => p.name).join(', ') : people + } + + formatRelationValue(value) { + if (!value) return [] + const relations = Array.isArray(value) ? value : [value] + const formatted = relations.map(r => r.title || 'Untitled') + return this.outputStringsOnly ? formatted.join(', ') : formatted + } + + fieldGeneratesId(field) { + return this.showGeneratedIds && (field.generates_auto_increment_id || field.generates_uuid) + } +} diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue new file mode 100644 index 000000000..1e7b116cc --- /dev/null +++ b/client/components/forms/components/MentionDropdown.vue @@ -0,0 +1,116 @@ + + + \ No newline at end of file diff --git a/client/components/forms/components/QuillyEditor.vue b/client/components/forms/components/QuillyEditor.vue new file mode 100644 index 000000000..854cb714c --- /dev/null +++ b/client/components/forms/components/QuillyEditor.vue @@ -0,0 +1,98 @@ + + + \ No newline at end of file diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index e61ce3fe7..48598de3c 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -140,9 +140,13 @@ key="submitted" class="px-2" > -

{ + this.submittedData = form.data() useAmplitude().logEvent('form_submission', { workspace_id: this.form.workspace_id, form_id: this.form.id diff --git a/client/components/open/forms/components/FirstSubmissionModal.vue b/client/components/open/forms/components/FirstSubmissionModal.vue index 25fbbf990..333990ee9 100644 --- a/client/components/open/forms/components/FirstSubmissionModal.vue +++ b/client/components/open/forms/components/FirstSubmissionModal.vue @@ -2,7 +2,7 @@