From 6c056f26925678b826b28d3c1c0de9dead3fca08 Mon Sep 17 00:00:00 2001
From: Frank
Date: Wed, 9 Oct 2024 14:26:13 +0100
Subject: [PATCH 1/9] variables and mentions
---
.../SubmissionConfirmationIntegration.php | 3 +-
.../Mail/Forms/SubmissionConfirmationMail.php | 39 +++-
api/app/Open/MentionParser.php | 55 +++++
.../HtmlPurifier/OpenFormsHtmlDefinition.php | 21 ++
api/config/purify.php | 6 +-
client/components/forms/MentionInput.vue | 189 ++++++++++++++++++
.../forms/RichTextAreaInput.client.vue | 96 ++++++---
client/components/forms/TextBlock.vue | 49 +++++
.../components/FormSubmissionFormatter.js | 105 ++++++++++
.../forms/components/MentionDropdown.vue | 116 +++++++++++
.../open/forms/OpenCompleteForm.vue | 12 +-
.../forms/components/FirstSubmissionModal.vue | 2 +-
.../FormSubmissionSettings.vue | 2 +
.../SubmissionConfirmationIntegration.vue | 18 +-
client/data/blocks_types.json | 60 ++++--
client/lib/quill/quillMentionExtension.js | 177 ++++++++++++++++
16 files changed, 880 insertions(+), 70 deletions(-)
create mode 100644 api/app/Open/MentionParser.php
create mode 100644 api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php
create mode 100644 client/components/forms/MentionInput.vue
create mode 100644 client/components/forms/TextBlock.vue
create mode 100644 client/components/forms/components/FormSubmissionFormatter.js
create mode 100644 client/components/forms/components/MentionDropdown.vue
create mode 100644 client/lib/quill/quillMentionExtension.js
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..2b0d249ee 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,13 @@ 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(),
+ 'fields' => $this->formattedData,
'form' => $form,
'integrationData' => $this->integrationData,
'noBranding' => $form->no_branding,
@@ -67,9 +72,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..f520763c5
--- /dev/null
+++ b/api/app/Open/MentionParser.php
@@ -0,0 +1,55 @@
+content = $content;
+ $this->data = $data;
+ }
+
+ public function parse()
+ {
+ return $this->replaceMentions();
+ }
+
+ 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)->filter(function ($item) use ($fieldId) {
+ return $item['id'] === $fieldId;
+ })->first()['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..a7c77e8a7 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|contenteditable],*[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/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue
new file mode 100644
index 000000000..fd70c464b
--- /dev/null
+++ b/client/components/forms/MentionInput.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue
index 26143045c..48f0da479 100644
--- a/client/components/forms/RichTextAreaInput.client.vue
+++ b/client/components/forms/RichTextAreaInput.client.vue
@@ -24,12 +24,25 @@
:style="inputStyle"
/>
+
+
+
+
+
@@ -37,49 +50,74 @@
import { Quill, VueEditor } from 'vue3-editor'
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
+import MentionDropdown from './components/MentionDropdown.vue'
+import registerMentionExtension from '~/lib/quill/quillMentionExtension.js'
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion')
export default {
name: 'RichTextAreaInput',
- components: { InputWrapper, VueEditor },
+ components: { InputWrapper, VueEditor, MentionDropdown },
props: {
...inputProps,
editorOptions: {
type: Object,
- default: () => {
- return {
- formats: [
- 'bold',
- 'color',
- 'font',
- 'italic',
- 'link',
- 'underline',
- 'header',
- 'indent',
- 'list'
- ]
+ default: () => ({
+ formats: [
+ 'bold',
+ 'color',
+ 'font',
+ 'italic',
+ 'link',
+ 'underline',
+ 'header',
+ 'indent',
+ 'list',
+ 'mention'
+ ],
+ modules: {
+ mention: {
+ mentions: [] // This will be populated with form fields
+ }
}
- }
+ })
},
editorToolbar: {
type: Array,
- default: () => {
- return [
- [{ header: 1 }, { header: 2 }],
- ['bold', 'italic', 'underline', 'link'],
- [{ list: 'ordered' }, { list: 'bullet' }],
- [{ color: [] }]
- ]
- }
+ default: () => [
+ [{ header: 1 }, { header: 2 }],
+ ['bold', 'italic', 'underline', 'link'],
+ [{ list: 'ordered' }, { list: 'bullet' }],
+ [{ color: [] }]
+ ]
+ },
+ mentions: {
+ type: Array,
+ default: () => []
+ },
+ enableMentions: {
+ type: Boolean,
+ default: false
}
},
- setup (props, context) {
+ setup(props, context) {
+ const editorOptions = {
+ ...props.editorOptions,
+ modules: {
+ ...props.editorOptions.modules,
+ mention: props.enableMentions ? { mentions: props.mentions } : undefined
+ }
+ }
+ const editorToolbar = props.enableMentions
+ ? [...props.editorToolbar, ['mention']]
+ : props.editorToolbar
return {
- ...useFormInput(props, context)
+ ...useFormInput(props, context),
+ editorOptions,
+ editorToolbar,
+ mentionState: registerMentionExtension(Quill)
}
}
}
@@ -120,4 +158,12 @@ export default {
@apply text-nt-blue;
}
}
+
+.ql-mention::after {
+ content: '@';
+ font-size: 18px;
+}
+span[mention] {
+ @apply max-w-[150px] truncate overflow-hidden bg-blue-100 text-blue-800 border border-blue-200 rounded-md px-1 inline-flex items-center align-baseline leading-tight text-sm relative;
+}
diff --git a/client/components/forms/TextBlock.vue b/client/components/forms/TextBlock.vue
new file mode 100644
index 000000000..71b3218dc
--- /dev/null
+++ b/client/components/forms/TextBlock.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
\ 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..49dcd0398
--- /dev/null
+++ b/client/components/forms/components/MentionDropdown.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+ Insert
+
+
+ Cancel
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue
index d0eb36516..12ec56747 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 @@
diff --git a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
index 1a5214388..41625e1d7 100644
--- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
+++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
@@ -129,6 +129,8 @@
-
-
25 ? `${data.field.name.slice(0, 25)}...` : data.field.name
+
+ return node
+
+ }
+
+
+
+
+ static value(node) {
+
+ return {
+
+ field_id: node.getAttribute('mention-field-id'),
+
+ field_name: node.getAttribute('mention-field-name'),
+
+ fallback: node.getAttribute('mention-fallback'),
+
+ }
+
+ }
+
+
+
+
+ static formats() {
+
+ return true
+
+ }
+
+}
+
+
+
+
+export default function registerMentionExtension(Quill) {
+
+ const mentionState = reactive({
+
+ open: false,
+
+ onInsert: null,
+
+ onCancel: null,
+
+ })
+
+
+
+
+ if (!Quill.imports['modules/mention']) {
+
+ Quill.register(MentionBlot)
+
+
+
+
+ class MentionModule {
+
+ constructor(quill, options) {
+
+ this.quill = quill
+
+ this.options = options
+
+ this.setupMentions()
+
+ }
+
+
+
+
+ setupMentions() {
+
+ this.quill.getModule('toolbar').addHandler('mention', () => {
+
+ const range = this.quill.getSelection()
+
+ if (range) {
+
+ mentionState.open = true
+
+ mentionState.onInsert = (mention) => {
+
+ this.insertMention(mention, range.index)
+
+ }
+
+ mentionState.onCancel = () => {
+
+ mentionState.open = false
+
+ }
+
+ }
+
+ })
+
+ }
+
+
+
+
+ insertMention(mention, index) {
+
+ this.quill.insertEmbed(index, 'mention', mention)
+
+ this.quill.insertText(index + 1, ' ')
+
+ this.quill.setSelection(index + 2)
+
+
+
+
+ nextTick(() => {
+
+ mentionState.open = false
+
+ })
+
+ }
+
+ }
+
+
+
+
+ Quill.register('modules/mention', MentionModule)
+
+ }
+
+
+
+
+ return mentionState
+
+}
\ No newline at end of file
From 70804efe0bcacb67bc618cebd9abf5ad770781f3 Mon Sep 17 00:00:00 2001
From: Frank
Date: Wed, 9 Oct 2024 15:56:25 +0100
Subject: [PATCH 2/9] fix lint
---
api/app/Mail/Forms/SubmissionConfirmationMail.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/app/Mail/Forms/SubmissionConfirmationMail.php b/api/app/Mail/Forms/SubmissionConfirmationMail.php
index 2b0d249ee..2bc417750 100644
--- a/api/app/Mail/Forms/SubmissionConfirmationMail.php
+++ b/api/app/Mail/Forms/SubmissionConfirmationMail.php
@@ -43,7 +43,7 @@ public function build()
{
$form = $this->event->form;
-
+
return $this
->replyTo($this->getReplyToEmail($form->creator->email))
->from($this->getFromEmail(), $this->integrationData->notification_sender)
From 4640b2630a848263587bff43ddf981c365341bd5 Mon Sep 17 00:00:00 2001
From: Frank
Date: Wed, 9 Oct 2024 16:32:48 +0100
Subject: [PATCH 3/9] add missing changes
---
.../mail/form/confirmation-submission-notification.blade.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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])
From 1b5dc997a312494745a8b703da8b8d695e1de1ed Mon Sep 17 00:00:00 2001
From: Frank
Date: Wed, 9 Oct 2024 16:59:33 +0100
Subject: [PATCH 4/9] fix tests
---
api/app/Mail/Forms/SubmissionConfirmationMail.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/api/app/Mail/Forms/SubmissionConfirmationMail.php b/api/app/Mail/Forms/SubmissionConfirmationMail.php
index 2bc417750..ab51190a0 100644
--- a/api/app/Mail/Forms/SubmissionConfirmationMail.php
+++ b/api/app/Mail/Forms/SubmissionConfirmationMail.php
@@ -49,6 +49,7 @@ public function build()
->from($this->getFromEmail(), $this->integrationData->notification_sender)
->subject($this->getSubject())
->markdown('mail.form.confirmation-submission-notification', [
+ 'notificationBody' => $this->getNotificationBody(),
'fields' => $this->formattedData,
'form' => $form,
'integrationData' => $this->integrationData,
From 3773313c50349641872552dc530111e0defa0291 Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 10 Oct 2024 11:02:30 +0100
Subject: [PATCH 5/9] update quilly, fix bugs
---
api/app/Open/MentionParser.php | 50 +++-
api/config/purify.php | 2 +-
.../Unit/Service/Forms/MentionParserTest.php | 196 ++++++++++++++++
.../forms/RichTextAreaInput.client.vue | 180 +++++++-------
.../forms/components/QuillyEditor.vue | 98 ++++++++
client/composables/lib/vForm/Form.js | 2 +-
client/lib/quill/quillMentionExtension.js | 219 +++++++-----------
client/package.json | 2 +-
8 files changed, 517 insertions(+), 232 deletions(-)
create mode 100644 api/tests/Unit/Service/Forms/MentionParserTest.php
create mode 100644 client/components/forms/components/QuillyEditor.vue
diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php
index f520763c5..4b9e4dd7d 100644
--- a/api/app/Open/MentionParser.php
+++ b/api/app/Open/MentionParser.php
@@ -2,6 +2,9 @@
namespace App\Open;
+use DOMDocument;
+use DOMXPath;
+
class MentionParser
{
private $content;
@@ -15,7 +18,48 @@ public function __construct($content, $data)
public function parse()
{
- return $this->replaceMentions();
+ $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()
@@ -42,9 +86,7 @@ private function replaceMentions()
private function getData($fieldId)
{
- $value = collect($this->data)->filter(function ($item) use ($fieldId) {
- return $item['id'] === $fieldId;
- })->first()['value'] ?? null;
+ $value = collect($this->data)->firstWhere('nf_id', $fieldId)['value'] ?? null;
if (is_object($value)) {
return (array) $value;
diff --git a/api/config/purify.php b/api/config/purify.php
index a7c77e8a7..f440c058c 100644
--- a/api/config/purify.php
+++ b/api/config/purify.php
@@ -40,7 +40,7 @@
'configs' => [
'default' => [
- '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|contenteditable],*[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',
diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php
new file mode 100644
index 000000000..79ed8667c
--- /dev/null
+++ b/api/tests/Unit/Service/Forms/MentionParserTest.php
@@ -0,0 +1,196 @@
+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('');
+
+});
+
+
+
+
+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');
+
+});
\ No newline at end of file
diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue
index 48f0da479..72ba8a538 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
+ }"
+ >
+
+
@@ -38,7 +39,7 @@
@@ -46,81 +47,63 @@
-
+
\ 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/composables/lib/vForm/Form.js b/client/composables/lib/vForm/Form.js
index b8e3c384c..cab2744cc 100644
--- a/client/composables/lib/vForm/Form.js
+++ b/client/composables/lib/vForm/Form.js
@@ -89,7 +89,7 @@ class Form {
Object.keys(this)
.filter((key) => !Form.ignore.includes(key))
.forEach((key) => {
- this[key] = JSON.parse(JSON.stringify(this.originalData[key]))
+ this[key] = cloneDeep(this.originalData[key])
})
}
diff --git a/client/lib/quill/quillMentionExtension.js b/client/lib/quill/quillMentionExtension.js
index 853fb10a8..ffb28d8d6 100644
--- a/client/lib/quill/quillMentionExtension.js
+++ b/client/lib/quill/quillMentionExtension.js
@@ -1,177 +1,130 @@
+import { reactive } from 'vue'
import Quill from 'quill'
+const Inline = Quill.import('blots/inline')
-import { reactive, nextTick } from 'vue'
-
-
-
-
-const Embed = Quill.import('blots/embed')
-
-
-
-
-class MentionBlot extends Embed {
-
- static blotName = "mention"
-
- static tagName = "span"
-
-
-
-
- static create(data) {
-
- const node = super.create()
-
- node.setAttribute('mention-field-id', data.field.id)
-
- node.setAttribute('mention-field-name', data.field.name)
-
- node.setAttribute('mention-fallback', data.fallback)
-
- node.setAttribute('contenteditable', 'false')
-
- node.setAttribute('mention', true)
-
-
-
-
- node.textContent = data.field.name.length > 25 ? `${data.field.name.slice(0, 25)}...` : data.field.name
-
- return node
-
- }
-
-
-
-
- static value(node) {
-
- return {
-
- field_id: node.getAttribute('mention-field-id'),
-
- field_name: node.getAttribute('mention-field-name'),
-
- fallback: node.getAttribute('mention-fallback'),
-
+export default function registerMentionExtension(Quill) {
+ class MentionBlot extends Inline {
+ static blotName = 'mention'
+ static tagName = 'SPAN'
+
+ static create(data) {
+ let node = super.create()
+ MentionBlot.setAttributes(node, data)
+ return node
}
- }
+ static setAttributes(node, data) {
+ node.setAttribute('contenteditable', 'false')
+ node.setAttribute('mention', 'true')
+
+ if (data && typeof data === 'object') {
+ node.setAttribute('mention-field-id', data.field?.nf_id || '')
+ node.setAttribute('mention-field-name', data.field?.name || '')
+ node.setAttribute('mention-fallback', data.fallback || '')
+ node.textContent = data.field?.name || ''
+ } else {
+ // Handle case where data is not an object (e.g., during undo)
+ node.textContent = data || ''
+ }
+ }
+ static formats(domNode) {
+ return {
+ 'mention-field-id': domNode.getAttribute('mention-field-id') || '',
+ 'mention-field-name': domNode.getAttribute('mention-field-name') || '',
+ 'mention-fallback': domNode.getAttribute('mention-fallback') || ''
+ }
+ }
+ format(name, value) {
+ if (name === 'mention' && value) {
+ MentionBlot.setAttributes(this.domNode, value)
+ } else {
+ super.format(name, value)
+ }
+ }
+ formats() {
+ let formats = super.formats()
+ formats['mention'] = MentionBlot.formats(this.domNode)
+ return formats
+ }
- static formats() {
+ static value(domNode) {
+ return {
+ field: {
+ nf_id: domNode.getAttribute('mention-field-id') || '',
+ name: domNode.getAttribute('mention-field-name') || ''
+ },
+ fallback: domNode.getAttribute('mention-fallback') || ''
+ }
+ }
- return true
+ // Override attach to ensure contenteditable is always set
+ attach() {
+ super.attach()
+ this.domNode.setAttribute('contenteditable', 'false')
+ }
+ length() {
+ return 1
+ }
}
-}
-
-
-
-
-export default function registerMentionExtension(Quill) {
+ Quill.register(MentionBlot)
const mentionState = reactive({
-
open: false,
-
onInsert: null,
-
onCancel: null,
-
})
+ class MentionModule {
+ constructor(quill, options) {
+ this.quill = quill
+ this.options = options
+ this.setupMentions()
+ }
-
- if (!Quill.imports['modules/mention']) {
-
- Quill.register(MentionBlot)
-
-
-
-
- class MentionModule {
-
- constructor(quill, options) {
-
- this.quill = quill
-
- this.options = options
-
- this.setupMentions()
-
- }
-
-
-
-
- setupMentions() {
-
- this.quill.getModule('toolbar').addHandler('mention', () => {
-
+ setupMentions() {
+ const toolbar = this.quill.getModule('toolbar')
+ if (toolbar) {
+ toolbar.addHandler('mention', () => {
const range = this.quill.getSelection()
-
if (range) {
-
mentionState.open = true
-
mentionState.onInsert = (mention) => {
-
this.insertMention(mention, range.index)
-
}
-
mentionState.onCancel = () => {
-
mentionState.open = false
-
}
-
}
-
})
-
}
-
-
-
-
- insertMention(mention, index) {
-
- this.quill.insertEmbed(index, 'mention', mention)
-
- this.quill.insertText(index + 1, ' ')
-
- this.quill.setSelection(index + 2)
-
-
-
-
- nextTick(() => {
-
- mentionState.open = false
-
- })
-
- }
-
}
+ insertMention(mention, index) {
+ mentionState.open = false
+ // Insert the mention
+ this.quill.insertEmbed(index, 'mention', mention, Quill.sources.USER)
+ // Calculate the length of the inserted mention
+ const mentionLength = this.quill.getLength() - index
- Quill.register('modules/mention', MentionModule)
+ nextTick(() => {
+ // Focus the editor
+ this.quill.focus()
+ // Set the selection after the mention
+ this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
+ })
+ }
}
-
-
+ Quill.register('modules/mention', MentionModule)
return mentionState
-
}
\ No newline at end of file
diff --git a/client/package.json b/client/package.json
index d5ca746c2..ba1c30512 100644
--- a/client/package.json
+++ b/client/package.json
@@ -59,6 +59,7 @@
"prismjs": "^1.24.1",
"qrcode": "^1.5.1",
"query-builder-vue-3": "^1.0.1",
+ "quill": "^2.0.2",
"tailwind-merge": "^2.3.0",
"tinymotion": "^0.2.0",
"v-calendar": "^3.1.2",
@@ -70,7 +71,6 @@
"vue-json-pretty": "^2.4.0",
"vue-notion": "^3.0.0-beta.1",
"vue-signature-pad": "^3.0.2",
- "vue3-editor": "^0.1.1",
"vuedraggable": "next",
"webcam-easy": "^1.1.1"
},
From f8c2b30a8e09cbd5fddee530ad1a44fe8e46ce71 Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 10 Oct 2024 11:14:20 +0100
Subject: [PATCH 6/9] fix lint
---
api/tests/Unit/Service/Forms/MentionParserTest.php | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php
index 79ed8667c..908f0bc1b 100644
--- a/api/tests/Unit/Service/Forms/MentionParserTest.php
+++ b/api/tests/Unit/Service/Forms/MentionParserTest.php
@@ -5,9 +5,6 @@
use App\Open\MentionParser;
-
-
-
test('it replaces mention elements with their corresponding values', function () {
$content = 'Hello Placeholder
';
@@ -193,4 +190,4 @@
expect($result)->toBe('some text replaced text dewde');
-});
\ No newline at end of file
+});
From 1e96b468c70258ed077b0c76e64f573a11140f6e Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 10 Oct 2024 12:07:13 +0100
Subject: [PATCH 7/9] apply fixes
---
client/components/forms/MentionInput.vue | 1 +
.../forms/RichTextAreaInput.client.vue | 3 +-
client/composables/useParseMention.js | 39 +++++++++++++++++++
3 files changed, 41 insertions(+), 2 deletions(-)
create mode 100644 client/composables/useParseMention.js
diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue
index fd70c464b..3dbb8369f 100644
--- a/client/components/forms/MentionInput.vue
+++ b/client/components/forms/MentionInput.vue
@@ -27,6 +27,7 @@
},
'pr-12'
]"
+ :placeholder="placeholder"
@input="onInput"
/>
{
content: '@';
font-size: 16px;
}
-.rich-editor {
+.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;
- cursor: default;
}
}
\ No newline at end of file
diff --git a/client/composables/useParseMention.js b/client/composables/useParseMention.js
new file mode 100644
index 000000000..59494d307
--- /dev/null
+++ b/client/composables/useParseMention.js
@@ -0,0 +1,39 @@
+import { FormSubmissionFormatter } from '~/components/forms/components/FormSubmissionFormatter'
+
+export function useParseMention(content, mentionsAllowed, form, formData) {
+ if (!mentionsAllowed || !form || !formData) {
+ return content
+ }
+
+ const formatter = new FormSubmissionFormatter(form, formData).setOutputStringsOnly()
+ const formattedData = formatter.getFormattedData()
+
+ // Create a new DOMParser
+ const parser = new DOMParser()
+ // Parse the content as HTML
+ const doc = parser.parseFromString(content, 'text/html')
+
+ // Find all elements with mention attribute
+ const mentionElements = doc.querySelectorAll('[mention]')
+
+ mentionElements.forEach(element => {
+ const fieldId = element.getAttribute('mention-field-id')
+ const fallback = element.getAttribute('mention-fallback')
+ const value = formattedData[fieldId]
+
+ if (value) {
+ if (Array.isArray(value)) {
+ element.textContent = value.join(', ')
+ } else {
+ element.textContent = value
+ }
+ } else if (fallback) {
+ element.textContent = fallback
+ } else {
+ element.remove()
+ }
+ })
+
+ // Return the processed HTML content
+ return doc.body.innerHTML
+}
\ No newline at end of file
From 92dc063b186f8edccce351f2da8eff0655095915 Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 10 Oct 2024 15:25:06 +0100
Subject: [PATCH 8/9] apply fixes
---
client/components/forms/TextBlock.vue | 22 +------------------
.../forms/components/MentionDropdown.vue | 2 +-
2 files changed, 2 insertions(+), 22 deletions(-)
diff --git a/client/components/forms/TextBlock.vue b/client/components/forms/TextBlock.vue
index 71b3218dc..8a2c7c641 100644
--- a/client/components/forms/TextBlock.vue
+++ b/client/components/forms/TextBlock.vue
@@ -5,7 +5,6 @@
\ No newline at end of file
diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue
index 49dcd0398..1e7b116cc 100644
--- a/client/components/forms/components/MentionDropdown.vue
+++ b/client/components/forms/components/MentionDropdown.vue
@@ -84,7 +84,7 @@
const selectedField = ref(null)
const fallbackValue = ref('')
const filteredMentions = computed(() => {
- return props.mentions.filter(mention => blocksTypes[mention.type].is_input)
+ return props.mentions.filter(mention => blocksTypes[mention.type]?.is_input ?? false)
})
function selectField(field, insert = false) {
selectedField.value = {...field}
From 5ffec25000d2fe76b3b4b496857f7f1412502cb0 Mon Sep 17 00:00:00 2001
From: Chirag Chhatrala
Date: Thu, 17 Oct 2024 13:51:44 +0530
Subject: [PATCH 9/9] Fix MentionParser
---
api/app/Open/MentionParser.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php
index 4b9e4dd7d..01c8e3c80 100644
--- a/api/app/Open/MentionParser.php
+++ b/api/app/Open/MentionParser.php
@@ -86,7 +86,7 @@ private function replaceMentions()
private function getData($fieldId)
{
- $value = collect($this->data)->firstWhere('nf_id', $fieldId)['value'] ?? null;
+ $value = collect($this->data)->firstWhere('id', $fieldId)['value'] ?? null;
if (is_object($value)) {
return (array) $value;