Date: Fri, 16 May 2025 12:30:31 +0200
Subject: [PATCH 03/11] Enhance Sentry Client Configuration for Improved Error
Handling
- Updated the `beforeSend` function in `sentry.client.config.ts` to improve error filtering by introducing checks for chunk loading errors and validation exceptions. This prevents unnecessary error reporting to Sentry, enhancing the relevance of logged errors.
- Refactored user information retrieval to use a local variable for better clarity and maintainability.
These changes aim to streamline error reporting and improve the overall reliability of the Sentry integration in the client application.
---
client/plugins/0.error-handler.client.js | 21 +++++++++++++++++
client/sentry.client.config.ts | 30 +++++++++++++++++-------
2 files changed, 43 insertions(+), 8 deletions(-)
create mode 100644 client/plugins/0.error-handler.client.js
diff --git a/client/plugins/0.error-handler.client.js b/client/plugins/0.error-handler.client.js
new file mode 100644
index 00000000..29025e51
--- /dev/null
+++ b/client/plugins/0.error-handler.client.js
@@ -0,0 +1,21 @@
+export default defineNuxtPlugin((nuxtApp) => {
+const router = useRouter()
+
+ router.onError((error) => {
+ if (
+ error.message.includes('Failed to fetch dynamically imported module') ||
+ error.message.includes('Failed to load resource')
+ ) {
+ window.location.reload()
+ }
+ })
+
+ nuxtApp.hook('app:error', (error) => {
+ if (
+ error.message.includes('Loading chunk') ||
+ error.message.includes('Failed to load resource')
+ ) {
+ window.location.reload()
+ }
+ })
+})
\ No newline at end of file
diff --git a/client/sentry.client.config.ts b/client/sentry.client.config.ts
index 150cce0b..4ace9217 100644
--- a/client/sentry.client.config.ts
+++ b/client/sentry.client.config.ts
@@ -24,23 +24,37 @@ Sentry.init({
beforeSend (event) {
if (event.exception?.values?.length) {
+ const errorType = event.exception.values[0]?.type || '';
+ const errorValue = event.exception.values[0]?.value || '';
+
// Don't send validation exceptions to Sentry
if (
- event.exception.values[0]?.type === 'FetchError'
- && (event.exception.values[0]?.value?.includes('422')
- || event.exception.values[0]?.value?.includes('401'))
- )
+ errorType === 'FetchError' &&
+ (errorValue.includes('422') || errorValue.includes('401'))
+ ) {
return null
+ }
+
+ // Filter out chunk loading errors
+ if (
+ errorValue.includes('Failed to fetch dynamically imported module') ||
+ errorValue.includes('Loading chunk') ||
+ errorValue.includes('Failed to load resource')
+ ) {
+ return null
+ }
}
+
const authStore = useAuthStore()
if (authStore.check) {
+ const user = authStore.user as { id?: string; email?: string } | null
Sentry.setUser({
- id: authStore.user?.id,
- email: authStore.user?.email
+ id: user?.id,
+ email: user?.email
})
event.user = {
- id: authStore.user?.id,
- email: authStore.user?.email
+ id: user?.id,
+ email: user?.email
}
}
return event
From b5517c6fce740c9923523825334865e7726399f4 Mon Sep 17 00:00:00 2001
From: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com>
Date: Fri, 16 May 2025 20:39:07 +0530
Subject: [PATCH 04/11] Fix quill and mentions (#758)
* Enhance MentionParser and Related Components for Improved Mention Handling
- Updated `MentionParser.php` to support the `mention="true"` syntax, allowing for more flexible mention parsing.
- Added tests in `MentionParserTest.php` to verify the handling of mentions with the `mention="true"` attribute, including support for URL-encoded field IDs.
- Refactored `MentionInput.vue`, `MentionDropdown.vue`, and `RichTextAreaInput.client.vue` to ensure consistent use of `mention-state` and improve mention dropdown functionality.
- Enhanced `quillMentionExtension.js` to better manage mention data and improve integration with Quill editor.
These changes aim to improve the functionality and reliability of the mention feature across the application, ensuring a better user experience.
* Refactor FormInformation Component for Improved Logic and Structure
- Updated `FormInformation.vue` to utilize the composition API with script setup syntax, enhancing readability and maintainability.
- Replaced `v-if` condition for form visibility with a computed property `isFormClosingOrClosed` for better clarity.
- Streamlined data handling by converting data and computed properties to reactive state and computed properties, respectively.
- Improved the copy settings logic to utilize refs, ensuring proper state management.
These changes aim to enhance the overall structure and functionality of the `FormInformation` component, providing a better user experience and code clarity.
---
api/app/Open/MentionParser.php | 28 +-
.../Unit/Service/Forms/MentionParserTest.php | 82 +++++
client/components/forms/MentionInput.vue | 297 +++++++++-------
.../forms/RichTextAreaInput.client.vue | 20 +-
.../forms/components/MentionDropdown.vue | 105 +++---
.../forms/components/QuillyEditor.vue | 31 +-
.../global/Settings/SettingsSection.vue | 2 +-
.../open/forms/OpenCompleteForm.vue | 2 +-
.../form-components/FormInformation.vue | 200 +++++------
client/lib/quill/quillMentionExtension.js | 335 ++++++++++--------
10 files changed, 633 insertions(+), 469 deletions(-)
diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php
index b679e8e3..5e254bda 100644
--- a/api/app/Open/MentionParser.php
+++ b/api/app/Open/MentionParser.php
@@ -4,6 +4,7 @@ namespace App\Open;
use DOMDocument;
use DOMXPath;
+use DOMElement;
class MentionParser
{
@@ -39,21 +40,24 @@ class MentionParser
libxml_use_internal_errors($internalErrors);
$xpath = new DOMXPath($doc);
- $mentionElements = $xpath->query("//span[@mention]");
+
+ $mentionElements = $xpath->query("//span[@mention or @mention='true']");
foreach ($mentionElements as $element) {
- $fieldId = $element->getAttribute('mention-field-id');
- $fallback = $element->getAttribute('mention-fallback');
- $value = $this->getData($fieldId);
+ if ($element instanceof DOMElement) {
+ $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($this->urlFriendly ? ',+' : ', ', $value) : $value);
- $element->parentNode->replaceChild($textNode, $element);
- } elseif ($fallback) {
- $textNode = $doc->createTextNode($fallback);
- $element->parentNode->replaceChild($textNode, $element);
- } else {
- $element->parentNode->removeChild($element);
+ if ($value !== null) {
+ $textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value);
+ $element->parentNode->replaceChild($textNode, $element);
+ } elseif ($fallback) {
+ $textNode = $doc->createTextNode($fallback);
+ $element->parentNode->replaceChild($textNode, $element);
+ } else {
+ $element->parentNode->removeChild($element);
+ }
}
}
diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php
index 0607e17b..61bdbb58 100644
--- a/api/tests/Unit/Service/Forms/MentionParserTest.php
+++ b/api/tests/Unit/Service/Forms/MentionParserTest.php
@@ -35,6 +35,18 @@ describe('MentionParser', function () {
expect($result)->toBe('Hello !
');
});
+ it('supports mention="true" syntax', function () {
+ $content = 'Hello Full Name
';
+ $data = [
+ ['id' => '123', 'value' => 'John Doe']
+ ];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello John Doe
');
+ });
+
describe('parseAsText', function () {
it('converts HTML to plain text with proper line breaks', function () {
$content = 'First line
Second line
';
@@ -144,6 +156,29 @@ describe('MentionParser', function () {
expect($result)->toBe('Tags: PHP, Laravel, Testing
');
});
+ test('it supports mention="true" attributes', function () {
+ $content = 'Hello Full Name
';
+ $data = [['id' => '123', 'value' => 'John Doe']];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello John Doe
');
+ });
+
+ test('it handles multiple mentions with mention="true" syntax', function () {
+ $content = 'Name and Title
';
+ $data = [
+ ['id' => '123', 'value' => 'John Doe'],
+ ['id' => '456', 'value' => 'Developer'],
+ ];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('John Doe and Developer
');
+ });
+
test('it preserves HTML structure', function () {
$content = 'Hello Placeholder
How are you?
';
$data = [['id' => '123', 'value' => 'World']];
@@ -174,6 +209,53 @@ describe('MentionParser', function () {
expect($result)->toBe('some text replaced text dewde');
});
+ test('it handles URL-encoded field IDs', function () {
+ $content = 'Hello Full Name
';
+ $data = [['id' => '%3ARGE', 'value' => 'John Doe']];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello John Doe
');
+ });
+
+ test('it handles URL-encoded field IDs with mention="true" syntax', function () {
+ $content = 'Hello Full Name
';
+ $data = [['id' => '%3ARGE', 'value' => 'John Doe']];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello John Doe
');
+ });
+
+ test('it handles multiple mentions with URL-encoded IDs and mention="true" syntax', function () {
+ $content = 'Full Name and Phone
';
+ $data = [
+ ['id' => '%3ARGE', 'value' => 'John Doe'],
+ ['id' => 'V%7D%40S', 'value' => '123-456-7890'],
+ ];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('John Doe and 123-456-7890
');
+ });
+
+ test('it recreates real-world example with URL-encoded IDs', function () {
+ $content = 'Hello there 👋
This is a confirmation that your submission was successfully saved.
Full NameContact FormPhone Number
';
+ $data = [
+ ['id' => '%3ARGE', 'value' => 'jujujujuju'],
+ ['id' => 'title', 'value' => 'jujuuj'],
+ ['id' => 'V%7D%40S', 'value' => '555-1234'],
+ ];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello there 👋
This is a confirmation that your submission was successfully saved.
jujujujujujujuuj555-1234
');
+ });
+
describe('urlFriendlyOutput', function () {
test('it encodes special characters in values', function () {
$content = 'Test: Placeholder
';
diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue
index 287b4708..f14ad612 100644
--- a/client/components/forms/MentionInput.vue
+++ b/client/components/forms/MentionInput.vue
@@ -3,12 +3,12 @@
-
+
-
+
-
+
-
+
-
-
-
-
\ No newline at end of file
+})
+
+defineExpose({
+ editableDiv,
+ compVal,
+ mentionState,
+ openMentionDropdown,
+ onInput,
+})
+
+
+
diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue
index 57aad7ae..be16f401 100644
--- a/client/components/forms/RichTextAreaInput.client.vue
+++ b/client/components/forms/RichTextAreaInput.client.vue
@@ -31,6 +31,7 @@
:options="quillOptions"
:disabled="disabled"
:style="inputStyle"
+ @ready="onEditorReady"
/>
@@ -59,7 +60,7 @@
@@ -103,12 +104,23 @@ watch(compVal, (val) => {
}
}, { immediate: true })
-// Move the mention extension registration to onMounted
-
-if (props.enableMentions && !Quill.imports['blots/mention']) {
+// Initialize mention extension
+if (props.enableMentions) {
+ // Register the mention extension with Quill
mentionState.value = registerMentionExtension(Quill)
}
+// Handle editor ready event
+const onEditorReady = (quillInstance) => {
+ // If we have a mention module, get its state
+ if (props.enableMentions && quillInstance) {
+ const mentionModule = quillInstance.getModule('mention')
+ if (mentionModule && mentionModule.state) {
+ mentionState.value = mentionModule.state
+ }
+ }
+}
+
const quillOptions = computed(() => {
const defaultOptions = {
placeholder: props.placeholder || '',
diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue
index 7ce580b5..8909c4e6 100644
--- a/client/components/forms/components/MentionDropdown.vue
+++ b/client/components/forms/components/MentionDropdown.vue
@@ -1,7 +1,7 @@
@@ -43,7 +43,7 @@
-
+
-
-
\ No newline at end of file
+}
+
+watch(() => props.mentionState.open, (newValue) => {
+ if (newValue) {
+ selectedField.value = null
+ fallbackValue.value = ''
+ }
+})
+
+const insertMention = () => {
+ if (selectedField.value && props.mentionState.onInsert) {
+ props.mentionState.onInsert({
+ field: selectedField.value,
+ fallback: fallbackValue.value
+ })
+ }
+}
+
+const cancel = () => {
+ if (props.mentionState.onCancel) {
+ props.mentionState.onCancel()
+ }
+}
+
\ No newline at end of file
diff --git a/client/components/forms/components/QuillyEditor.vue b/client/components/forms/components/QuillyEditor.vue
index 3d0e0b40..94978277 100644
--- a/client/components/forms/components/QuillyEditor.vue
+++ b/client/components/forms/components/QuillyEditor.vue
@@ -35,13 +35,40 @@ let quillInstance = null
const container = ref(null)
const model = ref(props.modelValue)
+// Safely paste HTML content with handling for empty content
const pasteHTML = (instance) => {
- instance.clipboard.dangerouslyPasteHTML(props.modelValue || '', 'silent')
+ if (!props.modelValue) {
+ instance.setContents([])
+ return
+ }
+
+ try {
+ instance.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
+ } catch (error) {
+ console.error('Error pasting HTML:', error)
+ // Fallback to setting empty content
+ instance.setContents([])
+ }
}
const initializeQuill = () => {
if (container.value) {
- quillInstance = new Quill(container.value, props.options)
+ // Merge default options with user options
+ const defaultOptions = {
+ formats: ['bold', 'color', 'font', 'code', 'italic', 'link', 'size', 'strike', 'script', 'underline', 'header', 'list', 'mention']
+ }
+
+ const mergedOptions = {
+ ...defaultOptions,
+ ...props.options,
+ modules: {
+ ...defaultOptions.modules,
+ ...(props.options.modules || {})
+ }
+ }
+
+ // Initialize Quill with merged options
+ quillInstance = new Quill(container.value, mergedOptions)
quillInstance.on('selection-change', (range, oldRange, source) => {
if (!range) {
diff --git a/client/components/global/Settings/SettingsSection.vue b/client/components/global/Settings/SettingsSection.vue
index 47eb4d65..990d44d3 100644
--- a/client/components/global/Settings/SettingsSection.vue
+++ b/client/components/global/Settings/SettingsSection.vue
@@ -1,6 +1,6 @@
diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue
index 1eef8df7..701bc16b 100644
--- a/client/components/open/forms/OpenCompleteForm.vue
+++ b/client/components/open/forms/OpenCompleteForm.vue
@@ -343,7 +343,7 @@ const triggerSubmit = async () => {
submissionId: submissionId.value
}).then(result => {
if (result) {
- submittedData.value = result || {}
+ submittedData.value = formManager.form.data()
if (result?.submission_id) {
submissionId.value = result.submission_id
diff --git a/client/components/open/forms/components/form-components/FormInformation.vue b/client/components/open/forms/components/form-components/FormInformation.vue
index 6c69f520..54ff6632 100644
--- a/client/components/open/forms/components/form-components/FormInformation.vue
+++ b/client/components/open/forms/components/form-components/FormInformation.vue
@@ -35,7 +35,7 @@
:options="visibilityOptions"
/>
-
diff --git a/client/lib/quill/quillMentionExtension.js b/client/lib/quill/quillMentionExtension.js
index 875b207d..ba683c11 100644
--- a/client/lib/quill/quillMentionExtension.js
+++ b/client/lib/quill/quillMentionExtension.js
@@ -1,177 +1,198 @@
-import { reactive } from 'vue'
+import { reactive, nextTick } from 'vue'
import Quill from 'quill'
-const Inline = Quill.import('blots/inline')
-const Clipboard = Quill.import('modules/clipboard')
-export default function registerMentionExtension(Quill) {
- // Extend Clipboard to handle pasted content
- class MentionClipboard extends Clipboard {
- convert(html) {
- const delta = super.convert(html)
- const processedDelta = delta.ops.reduce((newDelta, op) => {
- if (op.attributes && op.attributes.mention) {
- const mentionData = op.attributes.mention
- let isValid = false
- // Check for nested structure
- if (
- mentionData &&
- typeof mentionData === 'object' &&
- mentionData.field &&
- typeof mentionData.field === 'object' &&
- mentionData.field.id
- ) {
- isValid = true
- } else if (
- mentionData &&
- typeof mentionData === 'object' &&
- mentionData['mention-field-id']
- ) {
- // Transform flat structure to nested structure
- op.attributes.mention = {
- field: {
- id: mentionData['mention-field-id'],
- name: mentionData['mention-field-name'] || '',
- },
- fallback: mentionData['mention-fallback'] || '',
- }
- isValid = true
- }
- if (!isValid) {
- delete op.attributes.mention
- }
+// Core imports
+const ParchmentEmbed = Quill.import('parchment').EmbedBlot
+const Delta = Quill.import('delta')
+const Parchment = Quill.import('parchment')
+
+/**
+ * Utility to remove BOM and other zero-width characters from a string.
+ */
+function cleanString(str) {
+ if (typeof str !== 'string') return ''
+ return str.replace(/\uFEFF/g, '').replace(/\s+/g, ' ').trim()
+}
+
+export default function registerMentionExtension(QuillInstance) {
+ /**
+ * MentionBlot - Embeds a mention as a non-editable element
+ */
+ if (!QuillInstance.imports['formats/mention']) {
+ class MentionBlot extends ParchmentEmbed {
+ static blotName = 'mention'
+ static tagName = 'SPAN'
+ static scope = Parchment.Scope.INLINE
+
+ // Match any span with a 'mention' attribute
+ static matches(domNode) {
+ return (
+ domNode instanceof HTMLElement &&
+ domNode.tagName === this.tagName &&
+ domNode.hasAttribute('mention')
+ )
+ }
+
+ static create(value) {
+ const node = document.createElement(this.tagName)
+ node.setAttribute('contenteditable', 'false')
+
+ const data = (typeof value === 'object' && value !== null) ? value : { field: {}, fallback: '' }
+ data.field = (typeof data.field === 'object' && data.field !== null) ? data.field : {}
+
+ const fieldName = cleanString(data.field.name)
+ const fallbackText = cleanString(data.fallback)
+ const displayText = fieldName || fallbackText || 'mention'
+
+ node.setAttribute('mention', 'true')
+ node.setAttribute('mention-field-id', data.field.id || '')
+ node.setAttribute('mention-field-name', fieldName)
+ node.setAttribute('mention-fallback', fallbackText)
+
+ const textNode = document.createTextNode(displayText)
+ node.appendChild(textNode)
+
+ return node
+ }
+
+ static value(domNode) {
+ return {
+ field: {
+ id: domNode.getAttribute('mention-field-id') || '',
+ name: domNode.getAttribute('mention-field-name') || ''
+ },
+ fallback: domNode.getAttribute('mention-fallback') || ''
}
- newDelta.push(op)
- return newDelta
- }, [])
- return processedDelta
- }
- }
- Quill.register('modules/clipboard', MentionClipboard, true)
-
- class MentionBlot extends Inline {
- static blotName = 'mention'
- static tagName = 'SPAN'
-
- static create(data) {
- // Only create mention if we have valid data
- if (!data || !data.field || !data.field.id) {
- return null
}
- let node = super.create()
- MentionBlot.setAttributes(node, data)
- return node
- }
- static setAttributes(node, data) {
- // Only set attributes if we have valid data
- if (!data || !data.field || !data.field.id) {
- return
+ static formats(domNode) {
+ return MentionBlot.value(domNode)
}
- node.setAttribute('contenteditable', 'false')
- node.setAttribute('mention', 'true')
- node.setAttribute('mention-field-id', data.field.id || '')
- node.setAttribute('mention-field-name', data.field.name || '')
- node.setAttribute('mention-fallback', data.fallback || '')
- node.textContent = data.field.name || ''
- }
- 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') || ''
+ length() {
+ return 1
}
}
- 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.value(this.domNode)
- return formats
- }
-
- static value(domNode) {
- return {
- field: {
- id: domNode.getAttribute('mention-field-id') || '',
- name: domNode.getAttribute('mention-field-name') || ''
- },
- fallback: domNode.getAttribute('mention-fallback') || ''
- }
- }
-
- // Override attach to ensure contenteditable is always set
- attach() {
- super.attach()
- this.domNode.setAttribute('contenteditable', 'false')
- }
-
- length() {
- return 1
- }
+ // Register the blot with Quill
+ QuillInstance.register('formats/mention', MentionBlot)
}
- Quill.register(MentionBlot)
+ // Add clipboard matcher for handling mentions in pasted/loaded HTML
+ if (QuillInstance.clipboard && typeof QuillInstance.clipboard.addMatcher === 'function') {
+ QuillInstance.clipboard.addMatcher('span[mention]', (node, delta) => {
+ if (node.hasAttribute('mention')) {
+ const mentionData = {
+ field: {
+ id: node.getAttribute('mention-field-id') || '',
+ name: node.getAttribute('mention-field-name') || ''
+ },
+ fallback: node.getAttribute('mention-fallback') || ''
+ }
+
+ return new Delta().insert({ mention: mentionData })
+ }
+
+ return delta
+ })
+ }
- const mentionState = reactive({
- open: false,
- onInsert: null,
- onCancel: null,
- })
-
- class MentionModule {
- constructor(quill, options) {
- this.quill = quill
- this.options = options
-
- this.setupMentions()
- }
-
- 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)
+ /**
+ * MentionModule - Handles mention UI integration with Quill
+ */
+ if (!QuillInstance.imports['modules/mention']) {
+ class MentionModule {
+ constructor(quill, options = {}) {
+ this.quill = quill
+ this.options = options
+
+ // Reactive state for the UI component
+ this.state = reactive({
+ open: false,
+ onInsert: null,
+ onCancel: null
+ })
+
+ this.setupMentions()
+ }
+
+ setupMentions() {
+ const toolbar = this.quill.getModule('toolbar')
+ if (toolbar) {
+ toolbar.addHandler('mention', () => {
+ const range = this.quill.getSelection()
+ if (range) {
+ this.state.open = true
+ this.state.onInsert = (mentionData) => this.insertMention(mentionData, range.index)
+ this.state.onCancel = () => {
+ this.state.open = false
+ }
}
- mentionState.onCancel = () => {
- mentionState.open = false
- }
- }
+ })
+ }
+ }
+
+ insertMention(mentionData, index) {
+ if (!mentionData || typeof mentionData.field !== 'object' || mentionData.field === null) {
+ console.error("Invalid mention data for insertion:", mentionData)
+ return
+ }
+
+ this.state.open = false
+
+ // Handle selection
+ const selection = this.quill.getSelection()
+ if (selection && selection.length > 0) {
+ this.quill.deleteText(selection.index, selection.length, QuillInstance.sources.USER)
+ index = selection.index
+ }
+
+ // Prepare clean data for the blot
+ const blotData = {
+ field: {
+ id: mentionData.field.id || '',
+ name: cleanString(mentionData.field.name)
+ },
+ fallback: cleanString(mentionData.fallback)
+ }
+
+ // Insert mention as embed using the blotData
+ this.quill.insertEmbed(index, 'mention', blotData, QuillInstance.sources.USER)
+
+ // Move cursor after mention
+ nextTick(() => {
+ this.quill.focus()
+ this.quill.setSelection(index + 1, 0, QuillInstance.sources.SILENT)
})
}
}
-
- 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
-
- nextTick(() => {
- // Focus the editor
- this.quill.focus()
-
- // Set the selection after the mention
- this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
- })
+
+ // Register the module
+ QuillInstance.register('modules/mention', MentionModule)
+ }
+
+ // Patch getSemanticHTML to handle non-breaking spaces
+ if (typeof Quill.prototype.getSemanticHTML === 'function') {
+ if (!Quill.prototype.getSemanticHTML.isPatched) {
+ const originalGetSemanticHTML = Quill.prototype.getSemanticHTML
+ Quill.prototype.getSemanticHTML = function(index = 0, length) {
+ const currentLength = this.getLength()
+ const sanitizedIndex = Math.max(0, index)
+ const sanitizedLength = Math.max(0, Math.min(length ?? (currentLength - sanitizedIndex), currentLength - sanitizedIndex))
+ if (sanitizedIndex >= currentLength && currentLength > 0) {
+ return originalGetSemanticHTML.call(this, 0, 0)
+ }
+ const html = originalGetSemanticHTML.call(this, sanitizedIndex, sanitizedLength)
+ return html.replace(/ |\u00A0/g, ' ')
+ }
+ Quill.prototype.getSemanticHTML.isPatched = true
}
}
-
- Quill.register('modules/mention', MentionModule)
-
- return mentionState
+
+ // Return reactive state for component binding
+ return reactive({
+ open: false,
+ onInsert: null,
+ onCancel: null
+ })
}
\ No newline at end of file
From 1ba7805e3572df09e4a44fc39115d3b7714084ac Mon Sep 17 00:00:00 2001
From: JhumanJ
Date: Fri, 16 May 2025 19:58:53 +0200
Subject: [PATCH 05/11] Enhance Form Visibility Logic and UI Components
- Updated the `getIsClosedAttribute` method in `Form.php` to include a check for the form's visibility status, ensuring that forms marked as 'closed' are accurately reflected in their state.
- Modified `QuillyEditor.vue` to import additional Quill patches for improved functionality.
- Changed the alert color from yellow to amber in `OpenCompleteForm.vue` for better visual consistency.
- Refactored the form status display in `OpenCompleteForm.vue` and `show.vue` to utilize the new `FormStatusBadges` component, streamlining the UI and improving maintainability.
- Enhanced the `FormEditorNavbar.vue` to include icons for better user experience and clarity.
These changes aim to improve the accuracy of form visibility handling and enhance the overall user interface across various components.
---
api/app/Models/Forms/Form.php | 2 +-
.../forms/components/QuillyEditor.vue | 1 +
.../open/forms/OpenCompleteForm.vue | 32 ++---
.../forms/components/FormEditorNavbar.vue | 4 +-
.../forms/components/FormStatusBadges.vue | 110 ++++++++++++++++++
client/lib/quill/quillPatches.js | 22 ++++
client/pages/forms/[slug]/show.vue | 77 +-----------
client/pages/home.vue | 32 ++---
8 files changed, 170 insertions(+), 110 deletions(-)
create mode 100644 client/components/open/forms/components/FormStatusBadges.vue
create mode 100644 client/lib/quill/quillPatches.js
diff --git a/api/app/Models/Forms/Form.php b/api/app/Models/Forms/Form.php
index 42b6f2ec..afecd4b2 100644
--- a/api/app/Models/Forms/Form.php
+++ b/api/app/Models/Forms/Form.php
@@ -223,7 +223,7 @@ class Form extends Model implements CachableAttributes
public function getIsClosedAttribute()
{
- return $this->closes_at && now()->gt($this->closes_at);
+ return $this->visibility === 'closed' || ($this->closes_at && now()->gt($this->closes_at));
}
public function getFormPendingSubmissionKeyAttribute()
diff --git a/client/components/forms/components/QuillyEditor.vue b/client/components/forms/components/QuillyEditor.vue
index 94978277..1d0a43f7 100644
--- a/client/components/forms/components/QuillyEditor.vue
+++ b/client/components/forms/components/QuillyEditor.vue
@@ -8,6 +8,7 @@
\ No newline at end of file
diff --git a/client/lib/quill/quillPatches.js b/client/lib/quill/quillPatches.js
new file mode 100644
index 00000000..3811237b
--- /dev/null
+++ b/client/lib/quill/quillPatches.js
@@ -0,0 +1,22 @@
+import Quill from 'quill'
+
+// Self-executing function to patch Quill's prototype
+;(function installQuillFixes() {
+ // Store the original method
+ const originalGetSemanticHTML = Quill.prototype.getSemanticHTML
+
+ // Override the getSemanticHTML method
+ Quill.prototype.getSemanticHTML = function(index = 0, length) {
+ // Call the original method
+ const html = originalGetSemanticHTML.call(this, index, length || this.getLength())
+
+ // Apply fixes:
+ return html
+ // 1. Replace with regular spaces
+ .replace(/ /g, ' ')
+ // 2. Fix line breaks by replacing empty paragraphs with paragraphs containing
+ .replace(/<\/p>/g, '
')
+ }
+})()
+
+export default {}
\ No newline at end of file
diff --git a/client/pages/forms/[slug]/show.vue b/client/pages/forms/[slug]/show.vue
index 3d4d6c24..fc44bdf9 100644
--- a/client/pages/forms/[slug]/show.vue
+++ b/client/pages/forms/[slug]/show.vue
@@ -78,60 +78,11 @@
- Edited {{ form.last_edited_human }}
-
-
- Draft - not publicly accessible
-
-
- Closed - won't accept new submissions
-
-
- {{ tag }}
-
-
-
-
-
- This form stopped accepting submissions on the
- {{ displayClosesDate }}
-
-
- This form will stop accepting submissions on the
- {{ displayClosesDate }}
-
-
-
-
- The form is now closed because it reached its limit of
- {{ form.max_submissions_count }} submissions.
-
-
- This form will stop accepting submissions after
- {{ form.max_submissions_count }} submissions.
-
-
+
+
formsStore.getByKey(slug))
const workspace = computed(() => workspacesStore.getCurrent)
const loading = computed(() => formsStore.loading || workspacesStore.loading)
-const displayClosesDate = computed(() => {
- if (form.value && form.value.closes_at) {
- const dateObj = new Date(form.value.closes_at)
- return (
- dateObj.getFullYear() +
- "-" +
- String(dateObj.getMonth() + 1).padStart(2, "0") +
- "-" +
- String(dateObj.getDate()).padStart(2, "0") +
- " " +
- String(dateObj.getHours()).padStart(2, "0") +
- ":" +
- String(dateObj.getMinutes()).padStart(2, "0")
- )
- }
- return ""
-})
const tabsList = [
{
diff --git a/client/pages/home.vue b/client/pages/home.vue
index 5d155aae..d3e148f9 100644
--- a/client/pages/home.vue
+++ b/client/pages/home.vue
@@ -153,30 +153,13 @@
{{ form?.creator?.name }}
-
-
- Draft
-
-
- Closed
-
-
- {{ tag }}
-
-
+
+
+
Date: Mon, 19 May 2025 12:23:37 +0200
Subject: [PATCH 06/11] Enhance Form Functionality and Submission Logic
- Added `restart` and `formManager` to the exposed properties in `OpenCompleteForm.vue` for improved form management.
- Implemented a watcher in `FormEditorPreview.vue` to reset the form when switching modes, enhancing user experience during form interactions.
- Updated the `restartForm` function in `FormEditorPreview.vue` to handle errors gracefully and ensure the form can be restarted correctly.
- Modified the `useFormSubmission.js` to conditionally perform submissions based on the form mode strategy, allowing for mock submissions in preview mode.
These changes aim to improve the overall functionality and user experience of form handling within the application, ensuring better management and interaction with forms.
---
.../open/forms/OpenCompleteForm.vue | 4 ++-
.../form-components/FormEditorPreview.vue | 18 ++++++++++++-
.../forms/composables/useFormSubmission.js | 26 +++++++++++++++----
3 files changed, 41 insertions(+), 7 deletions(-)
diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue
index cca884ea..981d4fe0 100644
--- a/client/components/open/forms/OpenCompleteForm.vue
+++ b/client/components/open/forms/OpenCompleteForm.vue
@@ -398,7 +398,9 @@ const addPasswordError = (msg) => {
}
defineExpose({
- addPasswordError
+ addPasswordError,
+ restart,
+ formManager
})
diff --git a/client/components/open/forms/components/form-components/FormEditorPreview.vue b/client/components/open/forms/components/form-components/FormEditorPreview.vue
index bcfac8ff..eea6e71b 100644
--- a/client/components/open/forms/components/form-components/FormEditorPreview.vue
+++ b/client/components/open/forms/components/form-components/FormEditorPreview.vue
@@ -142,6 +142,13 @@ watch(() => form.value.dark_mode, () => {
handleDarkModeChange()
})
+// Watch for form mode changes to reset the form when switching modes
+watch(formMode, () => {
+ if (previewFormSubmitted.value) {
+ restartForm()
+ }
+})
+
onMounted(() => {
handleDarkModeChange()
})
@@ -163,7 +170,16 @@ function handleDarkModeChange() {
function restartForm() {
previewFormSubmitted.value = false
- formPreview.value.restart()
+
+ try {
+ // Try using the component reference first
+ if (formPreview.value && typeof formPreview.value.restart === 'function') {
+ formPreview.value.restart()
+ return
+ }
+ } catch (error) {
+ console.error('Error restarting form:', error)
+ }
}
function toggleExpand() {
diff --git a/client/lib/forms/composables/useFormSubmission.js b/client/lib/forms/composables/useFormSubmission.js
index 883ea4a3..f5576b4c 100644
--- a/client/lib/forms/composables/useFormSubmission.js
+++ b/client/lib/forms/composables/useFormSubmission.js
@@ -56,13 +56,29 @@ export function useFormSubmission(formConfig, form) {
// Prepare metadata only (form data will be auto-merged by Form.js)
const metadata = _prepareMetadata(options)
- // Use the vForm post method, which will automatically merge form data with metadata
- const response = await toValue(form).post(url, {
- data: metadata
- })
+ // Check if we should actually perform the submission based on mode strategy
+ const formModeStrategy = options.formModeStrategy
+ const shouldSubmit = formModeStrategy?.validation?.performActualSubmission !== false
+
+ let response
+
+ if (shouldSubmit) {
+ // Only perform the actual API call if the strategy allows it
+ response = await toValue(form).post(url, {
+ data: metadata
+ })
+ } else {
+ // Return a mock successful response when in preview/test mode
+ response = {
+ success: true,
+ data: {
+ message: 'Form preview submission (no actual submission performed)',
+ mock: true
+ }
+ }
+ }
// Optionally reset form after successful submission based on strategy
- const formModeStrategy = options.formModeStrategy
if (formModeStrategy?.submission?.resetAfterSubmit) {
toValue(form).reset()
}
From f9c734c82668bd0c0e04a7b42c277ff20f045d36 Mon Sep 17 00:00:00 2001
From: JhumanJ
Date: Mon, 19 May 2025 14:53:30 +0200
Subject: [PATCH 07/11] Update Loading State in OpenForm Component
- Changed the loading state binding in `OpenForm.vue` from `form.busy` to `isProcessing` for improved clarity and consistency in the form submission process.
- Introduced a computed property `isProcessing` to derive the loading state from the `formManager`'s state, enhancing the responsiveness of the UI during form interactions.
These changes aim to provide a more intuitive user experience by accurately reflecting the form's processing state during submissions.
---
client/components/open/forms/OpenForm.vue | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue
index 743aa836..b58fb392 100644
--- a/client/components/open/forms/OpenForm.vue
+++ b/client/components/open/forms/OpenForm.vue
@@ -63,7 +63,7 @@
{{ currentFieldsPageBreak.next_btn_text }}
@@ -163,6 +163,8 @@ const handleDragDropped = (data) => {
workingFormStore.moveField(oldTargetIndex, newTargetIndex)
}
}
+
+const isProcessing = computed(() => props.formManager.state.isProcessing)
diff --git a/client/components/pages/OpenFormFooter.vue b/client/components/pages/OpenFormFooter.vue
index 6f47a834..fa61bf8f 100644
--- a/client/components/pages/OpenFormFooter.vue
+++ b/client/components/pages/OpenFormFooter.vue
@@ -50,6 +50,18 @@
Technical Docs
+
+ Integrations
+
+
+ Report Abuse
+
Terms & Conditions
-
-
- Report Abuse
-
diff --git a/client/components/pages/notion/CustomBlock.vue b/client/components/pages/notion/CustomBlock.vue
new file mode 100644
index 00000000..c421616b
--- /dev/null
+++ b/client/components/pages/notion/CustomBlock.vue
@@ -0,0 +1,68 @@
+
+
+
+
+ Frequently Asked Questions
+
+
+
+
-
+ {{ question.label }}
+
+
+
+
+
+
+
+
+
+ {{ innerJson.title ? innerJson.title : 'Ready to upgrade your OpnForm forms?' }}
+
+
+ Try OpnForm for free
+
+
+
+
+
+
+
diff --git a/client/components/pages/notion/useNotionBlock.js b/client/components/pages/notion/useNotionBlock.js
new file mode 100644
index 00000000..763dbe04
--- /dev/null
+++ b/client/components/pages/notion/useNotionBlock.js
@@ -0,0 +1,107 @@
+import { computed } from 'vue'
+
+export default function useNotionBlock (props) {
+
+ const block = computed(() => {
+ const id = props.contentId || Object.keys(props.blockMap)[0]
+ return props.blockMap[id]
+ })
+
+ const value = computed(() => {
+ return block.value?.value
+ })
+
+ const format = computed(() => {
+ return value.value?.format
+ })
+
+ const icon = computed(() => {
+ return format.value?.page_icon || ''
+ })
+
+ const width = computed(() => {
+ return format.value?.block_width
+ })
+
+ const properties = computed(() => {
+ return value.value?.properties
+ })
+
+ const caption = computed(() => {
+ return properties.value?.caption
+ })
+
+ const description = computed(() => {
+ return properties.value?.description
+ })
+
+ const src = computed(() => {
+ return mapImageUrl(properties.value?.source[0][0], block.value)
+ })
+
+ const title = computed(() => {
+ return properties.value?.title
+ })
+
+ const alt = computed(() => {
+ return caption.value?.[0][0]
+ })
+
+ const type = computed(() => {
+ return value.value?.type
+ })
+
+ const visible = computed(() => {
+ return !props.hideList.includes(type.value)
+ })
+
+ const hasPageLinkOptions = computed(() => {
+ return props.pageLinkOptions?.component && props.pageLinkOptions?.href
+ })
+
+ const parent = computed(() => {
+ return props.blockMap[value.value?.parent_id]
+ })
+
+ const innerJson = computed(() => {
+ if (type.value !== 'code') return
+ if (properties.value.language.flat('Infinity').join('') !== 'JSON') {
+ return
+ }
+ try {
+ return JSON.parse(
+ title.value.flat(Infinity).join('').replace(/\n/g, '').replace(/\t/g, '').trim()
+ )
+ } catch (error) {
+ console.error('Failed to parse JSON',
+ error,
+ title.value.flat(Infinity).join('').replace(/\n/g, '').replace(/\t/g, '').trim()
+ )
+ return
+ }
+ })
+
+ function mapImageUrl (source) {
+ // Implement your mapImageUrl logic here
+ return source
+ }
+
+ return {
+ icon,
+ width,
+ properties,
+ caption,
+ description,
+ src,
+ title,
+ alt,
+ block,
+ value,
+ format,
+ type,
+ visible,
+ hasPageLinkOptions,
+ parent,
+ innerJson
+ }
+}
diff --git a/client/pages/integrations/[slug].vue b/client/pages/integrations/[slug].vue
new file mode 100644
index 00000000..c0b58550
--- /dev/null
+++ b/client/pages/integrations/[slug].vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+ Other Integrations
+
+
+
+ {{ page.Title }}
+
+
+
+
+ Discover our other Integrations
+
+
+
+
+
+
+ Whoops - Page not found
+
+
+
+
+
+
+
+
diff --git a/client/pages/integrations/index.vue b/client/pages/integrations/index.vue
new file mode 100644
index 00000000..b5649e4a
--- /dev/null
+++ b/client/pages/integrations/index.vue
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+
+ Available Integrations
+
+
+ Explore our powerful Integrations
+
+
+
+
+
+
+ Most Popular
+
+
+
+
+ {{ integration.title }}
+
+
+ {{ integration.description }}
+
+
+
+
+
+
+
+
+
+
+
+ Integration General Setup Guides
+
+
+ This can be another text
+
+
+
+
+
+ {{ guide.title }}
+
+
+ -
+ {{ index + 1 }}
+
+
+
+
+
+
+
+
+
+
+ Need help?
+
+
+ Visit our Help Center for detailed documentation!
+
+
+ Help Center
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/pages/privacy-policy.vue b/client/pages/privacy-policy.vue
index 6a2e54d6..26d90046 100644
--- a/client/pages/privacy-policy.vue
+++ b/client/pages/privacy-policy.vue
@@ -6,7 +6,7 @@
Privacy Policy
@@ -16,26 +16,19 @@
diff --git a/client/pages/terms-conditions.vue b/client/pages/terms-conditions.vue
index acdbe201..e76a4683 100644
--- a/client/pages/terms-conditions.vue
+++ b/client/pages/terms-conditions.vue
@@ -6,7 +6,7 @@
Terms & Conditions
@@ -16,26 +16,19 @@
diff --git a/client/stores/notion_cms.js b/client/stores/notion_cms.js
new file mode 100644
index 00000000..6d0b9538
--- /dev/null
+++ b/client/stores/notion_cms.js
@@ -0,0 +1,127 @@
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import opnformConfig from "~/opnform.config.js"
+
+function notionApiFetch (entityId, type = 'table') {
+ const apiUrl = opnformConfig.notion.worker
+ return useFetch(`${apiUrl}/${type}/${entityId}`)
+}
+
+function fetchNotionDatabasePages (databaseId) {
+ return notionApiFetch(databaseId)
+}
+
+function fetchNotionPageContent (pageId) {
+ return notionApiFetch(pageId, 'page')
+}
+
+export const useNotionCmsStore = defineStore('notion_cms', () => {
+
+ // State
+ const databases = ref({})
+ const pages = ref({})
+ const pageContents = ref({})
+ const slugToIdMap = ref({})
+
+ const loading = ref(false)
+
+ // Actions
+ const loadDatabase = (databaseId) => {
+ return new Promise((resolve, reject) => {
+ if (databases.value[databaseId]) return resolve()
+
+ loading.value = true
+ return fetchNotionDatabasePages(databaseId).then((response) => {
+ databases.value[databaseId] = response.data.value.map(page => formatId(page.id))
+ response.data.value.forEach(page => {
+ pages.value[formatId(page.id)] = {
+ ...page,
+ id: formatId(page.id)
+ }
+ const slug = page.Slug ?? page.slug ?? null
+ if (slug) {
+ setSlugToIdMap(slug, page.id)
+ }
+ })
+ loading.value = false
+ resolve()
+ }).catch((error) => {
+ loading.value = false
+ console.error(error)
+ reject(error)
+ })
+ })
+ }
+ const loadPage = async (pageId) => {
+ return new Promise((resolve, reject) => {
+ if (pageContents.value[pageId]) return resolve('in already here')
+ loading.value = true
+ return fetchNotionPageContent(pageId).then((response) => {
+ pageContents.value[formatId(pageId)] = response.data.value
+ loading.value = false
+ return resolve('in finishg')
+ }).catch((error) => {
+ console.error(error)
+ loading.value = false
+ return reject(error)
+ })
+ })
+ }
+
+ const loadPageBySlug = (slug) => {
+ if (!slugToIdMap.value[slug.toLowerCase()]) return
+ loadPage(slugToIdMap.value[slug.toLowerCase()])
+ }
+
+ const formatId = (id) => id.replaceAll('-', '')
+
+ const getPage = (pageId) => {
+ return {
+ ...pages.value[pageId],
+ blocks: getPageBody(pageId)
+ }
+ }
+
+ const getPageBody = (pageId) => {
+ return pageContents.value[pageId]
+ }
+
+ const setSlugToIdMap = (slug, pageId) => {
+ if (!slug) return
+ slugToIdMap.value[slug.toLowerCase()] = formatId(pageId)
+ }
+
+ const getPageBySlug = (slug) => {
+ if (!slug) return
+ const pageId = slugToIdMap.value[slug.toLowerCase()]
+ return getPage(pageId)
+ }
+
+// Getters
+ const databasePages = (databaseId) => computed(() => databases.value[databaseId]?.map(id => pages.value[id]) || [])
+ const pageContent = (pageId) => computed(() => pageContents.value[pageId])
+ const pageBySlug = (slug) => computed(() => getPageBySlug(slug))
+
+ return {
+ // state
+ loading,
+ databases,
+ pages,
+ pageContents,
+ slugToIdMap,
+
+ // actions
+ loadDatabase,
+ loadPage,
+ loadPageBySlug,
+ getPage,
+ getPageBody,
+ setSlugToIdMap,
+ getPageBySlug,
+
+ // getters
+ databasePages,
+ pageContent,
+ pageBySlug
+ }
+})
diff --git a/client/stores/notion_pages.js b/client/stores/notion_pages.js
deleted file mode 100644
index 608f78c9..00000000
--- a/client/stores/notion_pages.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { defineStore } from "pinia"
-import { useContentStore } from "~/composables/stores/useContentStore.js"
-import opnformConfig from "~/opnform.config.js"
-export const useNotionPagesStore = defineStore("notion_pages", () => {
- const contentStore = useContentStore()
-
- const load = (pageId) => {
- contentStore.startLoading()
-
- const apiUrl = opnformConfig.notion.worker
- return useFetch(`${apiUrl}/page/${pageId}`)
- .then(({ data }) => {
- const val = data.value
- val["id"] = pageId
- contentStore.save(val)
- })
- .finally(() => {
- contentStore.stopLoading()
- })
- }
-
- return {
- ...contentStore,
- load,
- }
-})
From 96786215aa694e233abfa29a3c739bfde3a7ddf7 Mon Sep 17 00:00:00 2001
From: JhumanJ
Date: Mon, 19 May 2025 15:16:33 +0200
Subject: [PATCH 09/11] Enhance Crisp Plugin Configuration Logic
- Updated the Crisp plugin configuration in `crisp.client.js` to prevent initialization when on the public form page. This change introduces a new condition that checks if the current route is the 'forms-slug' page, ensuring that the Crisp chat functionality is only enabled on appropriate pages.
These changes aim to improve the user experience by preventing unnecessary chat interactions on specific pages, thereby streamlining the application behavior.
---
client/plugins/crisp.client.js | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/client/plugins/crisp.client.js b/client/plugins/crisp.client.js
index a7d8cbf3..327f9e4d 100644
--- a/client/plugins/crisp.client.js
+++ b/client/plugins/crisp.client.js
@@ -3,7 +3,9 @@ import { Crisp } from "crisp-sdk-web"
export default defineNuxtPlugin(() => {
const isIframe = useIsIframe()
const crispWebsiteId = useRuntimeConfig().public.crispWebsiteId
- if (crispWebsiteId && !isIframe) {
+ const isPublicFormPage = useRoute().name === 'forms-slug'
+
+ if (crispWebsiteId && !isIframe && !isPublicFormPage) {
Crisp.configure(crispWebsiteId)
window.Crisp = Crisp
}
From 1c26e282c5a2348be2f811695f75ecbe43fe0561 Mon Sep 17 00:00:00 2001
From: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com>
Date: Mon, 19 May 2025 19:18:56 +0530
Subject: [PATCH 10/11] Fix form initialization (#759)
* Fix form initialization
* improve condition
---
.../composables/useFormInitialization.js | 185 ++++++++++++------
1 file changed, 126 insertions(+), 59 deletions(-)
diff --git a/client/lib/forms/composables/useFormInitialization.js b/client/lib/forms/composables/useFormInitialization.js
index ebb989f7..9e224007 100644
--- a/client/lib/forms/composables/useFormInitialization.js
+++ b/client/lib/forms/composables/useFormInitialization.js
@@ -1,11 +1,133 @@
import { toValue } from 'vue'
import { opnFetch } from '~/composables/useOpnApi.js'
+import clonedeep from 'clone-deep'
/**
* @fileoverview Composable for initializing form data, with complete handling of
* form state persistence, URL parameters, and default values.
*/
export function useFormInitialization(formConfig, form, pendingSubmission) {
+
+ /**
+ * Main method to initialize the form data.
+ * Follows a clear priority order:
+ * 1. Load from submission ID (if provided)
+ * 2. Load from pendingSubmission (localStorage) - client-side only
+ * 3. Apply URL parameters
+ * 4. Apply default values for fields
+ *
+ * @param {Object} options - Initialization options
+ * @param {String} [options.submissionId] - ID of submission to load
+ * @param {URLSearchParams} [options.urlParams] - URL parameters
+ * @param {Object} [options.defaultData] - Default data to apply
+ * @param {Array} [options.fields] - Form fields for special handling
+ */
+ const initialize = async (options = {}) => {
+ const config = toValue(formConfig)
+
+ // 1. Reset form state
+ form.reset()
+ form.errors.clear()
+
+ // 2. Try loading from submission ID
+ if (options.submissionId) {
+ const loaded = await tryLoadFromSubmissionId(options.submissionId)
+ if (loaded) return // Exit if loaded successfully
+ }
+
+ // 3. Try loading from pendingSubmission
+ if (!(options.skipPendingSubmission ?? false) && tryLoadFromPendingSubmission()) {
+ updateSpecialFields()
+ return // Exit if loaded successfully
+ }
+
+ // 4. Apply URL parameters
+ if (!(options.skipUrlParams ?? false) && options.urlParams) {
+ applyUrlParameters(options.urlParams)
+ }
+
+ // 5. Apply special field handling
+ updateSpecialFields()
+
+ // 6. Apply default data from config or options
+ const defaultValuesToApply = options.defaultData || config?.default_data
+ if (defaultValuesToApply) {
+ applyDefaultValues(defaultValuesToApply, config?.properties)
+ }
+
+ // 7. Process any select fields to ensure IDs are converted to names
+ // This is crucial when receiving data that might contain IDs instead of names
+ const currentData = form.data()
+ if (Object.keys(currentData).length > 0) {
+ resetAndFill(currentData)
+ }
+ }
+
+ /**
+ * Wrapper for form.resetAndFill that converts select option IDs to names
+ * @param {Object} formData - Form data to clean and fill
+ */
+ const resetAndFill = (formData) => {
+ if (!formData) {
+ form.reset()
+ return
+ }
+
+ // Clone the data to avoid mutating the original
+ const cleanData = clonedeep(formData)
+
+ // Process select fields to convert IDs to names
+ if (!formConfig.value || !formConfig.value.properties || !Array.isArray(formConfig.value.properties)) {
+ // If properties aren't available, just use the data as is
+ form.resetAndFill(cleanData)
+ return
+ }
+
+ // Iterate through form fields to process select fields
+ formConfig.value.properties.forEach(field => {
+ // Basic validation
+ if (!field || typeof field !== 'object') return
+ if (!field.id || !field.type) return
+ // Skip only when value is truly undefined or null
+ if (cleanData[field.id] === undefined || cleanData[field.id] === null) return
+
+ // Process checkbox fields - convert string and numeric values to boolean
+ if (field.type === 'checkbox') {
+ const value = cleanData[field.id]
+ if (typeof value === 'string' && value.toLowerCase() === 'true' || value === '1' || value === 1) {
+ cleanData[field.id] = true
+ } else if (typeof value === 'string' && value.toLowerCase() === 'false' || value === '0' || value === 0) {
+ cleanData[field.id] = false
+ }
+ }
+ // Only process select, multi_select fields
+ else if (['select', 'multi_select'].includes(field.type)) {
+ // Make sure the field has options
+ if (!field[field.type] || !Array.isArray(field[field.type].options)) return
+
+ const options = field[field.type].options
+
+ // Process array values (multi-select)
+ if (Array.isArray(cleanData[field.id])) {
+ cleanData[field.id] = cleanData[field.id].map(optionId => {
+ const option = options.find(opt => opt.id === optionId)
+ return option ? option.name : optionId
+ })
+ }
+ // Process single values (select)
+ else {
+ const option = options.find(opt => opt.id === cleanData[field.id])
+ if (option) {
+ cleanData[field.id] = option.name
+ }
+ }
+ }
+ })
+
+ // Fill with cleaned data
+ form.resetAndFill(cleanData)
+ }
+
/**
* Applies URL parameters to the form data.
* @param {URLSearchParams} params - The URL search parameters.
@@ -94,7 +216,7 @@ export function useFormInitialization(formConfig, form, pendingSubmission) {
return opnFetch(`/forms/${slug}/submissions/${submissionIdValue}`)
.then(submissionData => {
if (submissionData.data) {
- form.resetAndFill(submissionData.data)
+ resetAndFill(submissionData.data)
return true
} else {
console.warn(`Submission ${submissionIdValue} for form ${slug} loaded but returned no data.`)
@@ -131,69 +253,14 @@ export function useFormInitialization(formConfig, form, pendingSubmission) {
return false
}
- form.resetAndFill(pendingData)
+ resetAndFill(pendingData)
return true
}
- /**
- * Main method to initialize the form data.
- * Follows a clear priority order:
- * 1. Load from submission ID (if provided)
- * 2. Load from pendingSubmission (localStorage) - client-side only
- * 3. Apply URL parameters
- * 4. Apply default values for fields
- *
- * @param {Object} options - Initialization options
- * @param {String} [options.submissionId] - ID of submission to load
- * @param {URLSearchParams} [options.urlParams] - URL parameters
- * @param {Object} [options.defaultData] - Default data to apply
- * @param {Array} [options.fields] - Form fields for special handling
- */
- const initialize = async (options = {}) => {
- const config = toValue(formConfig)
-
- // 1. Reset form state
- form.reset()
- form.errors.clear()
-
- // 2. Try loading from submission ID
- if (options.submissionId) {
- const loaded = await tryLoadFromSubmissionId(options.submissionId)
- if (loaded) return // Exit if loaded successfully
- }
-
- // 3. Try loading from pendingSubmission
- if (!(options.skipPendingSubmission ?? false) && tryLoadFromPendingSubmission()) {
- updateSpecialFields()
- return // Exit if loaded successfully
- }
-
- // 4. Start with empty form data
- const formData = {}
-
- // 5. Apply URL parameters
- if (!(options.skipUrlParams ?? false) && options.urlParams) {
- applyUrlParameters(options.urlParams)
- }
-
- // 6. Apply special field handling
- updateSpecialFields()
-
- // 7. Apply default data from config or options
- const defaultValuesToApply = options.defaultData || config?.default_data
- if (defaultValuesToApply) {
- applyDefaultValues(defaultValuesToApply, config?.properties)
- }
-
- // 8. Fill the form with the collected data
- if (Object.keys(formData).length > 0) {
- form.resetAndFill(formData)
- }
- }
-
return {
initialize,
applyUrlParameters,
- applyDefaultValues
+ applyDefaultValues,
+ resetAndFill // Export our wrapped function for use elsewhere
}
}
\ No newline at end of file
From 1b67cd808b7d6e2ed46f5b839c4781abe14746ba Mon Sep 17 00:00:00 2001
From: JhumanJ
Date: Mon, 19 May 2025 15:54:35 +0200
Subject: [PATCH 11/11] Update GTM Configuration and Dependencies
- Added a new `enabled` property to the GTM configuration in `gtm.js`, allowing for conditional enabling of the GTM plugin.
- Updated the `package-lock.json` to include the latest versions of `@gtm-support/vue-gtm` and `@gtm-support/core`, ensuring compatibility and access to new features.
- Modified the `onMounted` lifecycle hook in `FeatureBase.vue` to include a check for the `user` state, preventing script loading when the user is not available.
These changes aim to enhance the GTM integration by providing more control over its activation and ensuring that the latest dependencies are utilized for improved functionality.
---
client/components/vendor/FeatureBase.vue | 3 +-
client/gtm.js | 3 +-
client/package-lock.json | 49 ++++++++++++++++++------
client/plugins/gtm.client.js | 31 +++++++++++++++
4 files changed, 72 insertions(+), 14 deletions(-)
create mode 100644 client/plugins/gtm.client.js
diff --git a/client/components/vendor/FeatureBase.vue b/client/components/vendor/FeatureBase.vue
index 61ec67d1..54a156be 100644
--- a/client/components/vendor/FeatureBase.vue
+++ b/client/components/vendor/FeatureBase.vue
@@ -54,7 +54,7 @@ const setupForUser = () => {
}
onMounted(() => {
- if (import.meta.server) return
+ if (import.meta.server || !user.value) return
// Setup base
if (
@@ -66,7 +66,6 @@ onMounted(() => {
}
}
- if (!user.value) return
loadScript()
try {
setupForUser()
diff --git a/client/gtm.js b/client/gtm.js
index c838cef6..8dce6517 100644
--- a/client/gtm.js
+++ b/client/gtm.js
@@ -1,5 +1,6 @@
export default {
id: process.env.NUXT_PUBLIC_GTM_CODE, // Your GTM single container ID, array of container ids ['GTM-xxxxxx', 'GTM-yyyyyy'] or array of objects [{id: 'GTM-xxxxxx', queryParams: { gtm_auth: 'abc123', gtm_preview: 'env-4', gtm_cookies_win: 'x'}}, {id: 'GTM-yyyyyy', queryParams: {gtm_auth: 'abc234', gtm_preview: 'env-5', gtm_cookies_win: 'x'}}], // Your GTM single container ID or array of container ids ['GTM-xxxxxx', 'GTM-yyyyyy']
debug: false, // Whether or not display console logs debugs (optional)
- devtools: false, // (optional)
+ devtools: false, // (optional),
+ enabled: false, // Disabled by default, will be enabled conditionally by our plugin
}
diff --git a/client/package-lock.json b/client/package-lock.json
index d093ec70..b648ab91 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -8,6 +8,7 @@
"hasInstallScript": true,
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
+ "@gtm-support/vue-gtm": "^3.1.0",
"@iconify-json/material-symbols": "^1.2.4",
"@nuxt/ui": "^2.19.2",
"@pinia/nuxt": "^0.11.0",
@@ -1448,26 +1449,24 @@
"license": "MIT"
},
"node_modules/@gtm-support/core": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-2.3.1.tgz",
- "integrity": "sha512-eD0hndQjhgKm5f/7IA9fZYujmHiVMY+fnYv4mdZSmz5XJQlS4TiTmpdZx2l7I2A9rI9J6Ysz8LpXYYNo/Xq4LQ==",
- "dev": true,
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-3.0.1.tgz",
+ "integrity": "sha512-SctcoqvvAbGAgZzOb7DZ4wjbZF3ZS7Las3qIEByv6g7mzPf4E9LpRXcQzjmywYLcUx2jys7PWJAa3s5slvj/0w==",
"license": "MIT"
},
"node_modules/@gtm-support/vue-gtm": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@gtm-support/vue-gtm/-/vue-gtm-2.2.0.tgz",
- "integrity": "sha512-7nhBTRkTG0mD+7r7JvNalJz++YwszZk0oP1HIY6fCgz6wNKxT6LuiXCqdPrZmNPe/WbPIKuqxGZN5s+i6NZqow==",
- "dev": true,
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@gtm-support/vue-gtm/-/vue-gtm-3.1.0.tgz",
+ "integrity": "sha512-kGUnCI+Z5lBeCKd7rzgU7UoFU8Q0EkJfh17SgeyAyx8cLdISqeq54BNJKZrME3WXersoigLZVJ1GLs0buYD3lA==",
"license": "MIT",
"dependencies": {
- "@gtm-support/core": "^2.0.0"
+ "@gtm-support/core": "^3.0.1"
},
"optionalDependencies": {
- "vue-router": ">= 4.1.0 < 5.0.0"
+ "vue-router": ">= 4.4.1 < 5.0.0"
},
"peerDependencies": {
- "vue": ">= 3.2.0 < 4.0.0"
+ "vue": ">= 3.2.26 < 4.0.0"
},
"peerDependenciesMeta": {
"vue-router": {
@@ -9117,6 +9116,34 @@
"nuxt": "^3.0.0"
}
},
+ "node_modules/@zadigetvoltaire/nuxt-gtm/node_modules/@gtm-support/core": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-2.3.1.tgz",
+ "integrity": "sha512-eD0hndQjhgKm5f/7IA9fZYujmHiVMY+fnYv4mdZSmz5XJQlS4TiTmpdZx2l7I2A9rI9J6Ysz8LpXYYNo/Xq4LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@zadigetvoltaire/nuxt-gtm/node_modules/@gtm-support/vue-gtm": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@gtm-support/vue-gtm/-/vue-gtm-2.2.0.tgz",
+ "integrity": "sha512-7nhBTRkTG0mD+7r7JvNalJz++YwszZk0oP1HIY6fCgz6wNKxT6LuiXCqdPrZmNPe/WbPIKuqxGZN5s+i6NZqow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@gtm-support/core": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "vue-router": ">= 4.1.0 < 5.0.0"
+ },
+ "peerDependencies": {
+ "vue": ">= 3.2.0 < 4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vue-router": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@zadigetvoltaire/nuxt-gtm/node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
diff --git a/client/plugins/gtm.client.js b/client/plugins/gtm.client.js
new file mode 100644
index 00000000..d2c8c338
--- /dev/null
+++ b/client/plugins/gtm.client.js
@@ -0,0 +1,31 @@
+import gtmConfig from '../gtm'
+
+export default defineNuxtPlugin(() => {
+ const route = useRoute()
+ const isIframe = useIsIframe()
+ const isPublicFormPage = route.name === 'forms-slug'
+
+ // Only enable GTM if not in a form page (for respondents) and not in an iframe
+ if (!isPublicFormPage && !isIframe && process.env.NUXT_PUBLIC_GTM_CODE) {
+ // Initialize GTM manually only when needed
+ const gtm = useGtm()
+
+ // Override the enabled setting to true for this session
+ gtmConfig.enabled = true
+
+ // Watch for route changes to track page views
+ watch(() => route.fullPath, () => {
+ if (!route.name || route.name !== 'forms-slug') {
+ gtm.trackView(route.name, route.fullPath)
+ }
+ }, { immediate: true })
+
+ return {
+ provide: {
+ gtm
+ }
+ }
+ }
+
+ return {}
+})
\ No newline at end of file