From 917c2acac1a5ce108c62f7c012accd994bfae197 Mon Sep 17 00:00:00 2001 From: Afiq Date: Thu, 7 Aug 2025 11:54:39 +0800 Subject: [PATCH] Enhance Custom HTML Component Functionality and User Experience - Improved the rendering of the custom HTML component in ComponentPreview.vue, adding a structured layout for labels, help text, and content display. - Introduced helper methods for scoped CSS and safe HTML content rendering, ensuring secure and styled output. - Updated FormBuilderComponents.vue to categorize the custom HTML component, enhancing organization within the form builder. - Enhanced FormBuilderFieldSettingsModal.vue with a tabbed interface for editing HTML, CSS, and JavaScript, improving usability and clarity for users. - Added content statistics to provide users with feedback on the length of their HTML, CSS, and JavaScript inputs, fostering better content management. - Refined styles across components to ensure a cohesive and visually appealing user interface. --- components/ComponentPreview.vue | 243 +++++++- components/FormBuilderComponents.vue | 600 ++++++++++++++++++- components/FormBuilderFieldSettingsModal.vue | 580 ++++++++++++++++-- pages/form-builder/index.vue | 2 - 4 files changed, 1339 insertions(+), 86 deletions(-) diff --git a/components/ComponentPreview.vue b/components/ComponentPreview.vue index b906ef7..a5f5be3 100644 --- a/components/ComponentPreview.vue +++ b/components/ComponentPreview.vue @@ -629,20 +629,34 @@
- +
+ +
+ +
+ + +
+ {{ component.props.help }} +
+ + +
+ +
+ + +
+
+
@@ -665,7 +679,7 @@ import { useNuxtApp } from '#app'; import { useFormBuilderStore } from '~/stores/formBuilder'; import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue'; import { safeGetField } from '~/composables/safeGetField'; -import { onMounted, onUnmounted, watch, computed } from 'vue'; +import { onMounted, onUnmounted, watch, computed, nextTick } from 'vue'; import draggable from 'vuedraggable'; const props = defineProps({ @@ -1711,6 +1725,163 @@ const getButtonSizeClass = (size) => { }; return sizeClasses[size] || sizeClasses['md']; }; + +// Custom HTML component helper methods +const getScopedCss = (component) => { + const cssContent = component.props.cssContent || + // Fallback to default CSS from FormBuilderComponents.vue + `.custom-component { + padding: 20px; + border: 2px solid #3b82f6; + border-radius: 8px; + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + text-align: center; +} + +.custom-component h3 { + color: #1e40af; + margin-bottom: 10px; +} + +.custom-component button { + background: #3b82f6; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + margin-top: 10px; +} + +.custom-component button:hover { + background: #2563eb; +}`; + + const uniqueId = `custom-html-${component.id}`; + + // Add scope to CSS rules + const scopedRules = cssContent.replace( + /([^@{}]+)\s*{/g, + `#${uniqueId} $1 {` + ); + + return ``; +}; + +const getSafeHtmlContent = (component) => { + // Get HTML content from component props + const htmlContent = component.props.htmlContent || + // Fallback to the default content from FormBuilderComponents.vue + `
+

Custom HTML Component

+

Edit this HTML to create your custom design.

+ +
`; + + const previewMode = component.props.previewMode || 'safe'; + + if (previewMode === 'safe') { + // Remove script tags and event handlers for safe mode + let safeContent = htmlContent + // Remove script tags completely + .replace(/)<[^<]*)*<\/script>/gi, '') + // Remove event handler attributes more carefully + .replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '') + // Remove javascript: protocol + .replace(/javascript:/gi, ''); + + return safeContent; + } + return htmlContent; +}; + +// Execute custom JavaScript in a controlled environment +const executeCustomScript = (component) => { + const jsContent = component.props.jsContent; + const allowScripts = component.props.allowScripts !== false; + const previewMode = component.props.previewMode || 'safe'; + + if (!allowScripts || !jsContent || previewMode === 'safe') { + return; + } + + try { + const uniqueId = `custom-html-${component.id}`; + const element = document.getElementById(uniqueId); + + if (!element) { + console.warn('Custom HTML element not found for script execution'); + return; + } + + // Create a safe execution context + const scriptContext = { + element: element, + console: console, + + // Helper functions for form interaction + getValue: (fieldName) => { + // Access form data from the form store + return formStore.previewFormData ? formStore.previewFormData[fieldName] : undefined; + }, + + setValue: (fieldName, value) => { + // Set form field value in the form store + if (formStore.previewFormData) { + formStore.previewFormData[fieldName] = value; + // Also emit an event to notify the parent form about the change + emit('form-data-updated', { fieldName, value }); + } + }, + + // Safe DOM methods + querySelector: (selector) => element?.querySelector(selector), + querySelectorAll: (selector) => element?.querySelectorAll(selector), + }; + + // Create function with custom context + const scriptFunction = new Function('console', 'element', 'getValue', 'setValue', 'querySelector', 'querySelectorAll', jsContent); + + // Execute with controlled context + scriptFunction.call( + scriptContext, + scriptContext.console, + scriptContext.element, + scriptContext.getValue, + scriptContext.setValue, + scriptContext.querySelector, + scriptContext.querySelectorAll + ); + + } catch (error) { + console.warn('Custom HTML script execution error:', error); + } +}; + +// Watch for custom HTML components and execute their scripts +watch(() => props.component, (newComponent) => { + if (newComponent && newComponent.type === 'customHtml') { + nextTick(() => { + executeCustomScript(newComponent); + }); + } +}, { deep: true }); + +// Execute scripts for custom HTML components on mount +onMounted(() => { + if (props.component && props.component.type === 'customHtml') { + nextTick(() => { + executeCustomScript(props.component); + }); + } +}); \ No newline at end of file diff --git a/components/FormBuilderComponents.vue b/components/FormBuilderComponents.vue index 157689c..feac1ac 100644 --- a/components/FormBuilderComponents.vue +++ b/components/FormBuilderComponents.vue @@ -115,6 +115,26 @@ + + +
+

Custom HTML

+
+
+ + {{ component.name }} +
+
+
@@ -736,7 +756,7 @@ const availableComponents = [ { type: 'customHtml', name: 'Custom HTML', - category: 'Layout', + category: 'Custom HTML', icon: 'material-symbols:code', description: 'Custom HTML with CSS and JavaScript', defaultProps: { @@ -746,11 +766,10 @@ const availableComponents = [ htmlContent: `

Custom HTML Component

Edit this HTML to create your custom design.

- +
`, cssContent: `.custom-component { padding: 20px; - border: 2px solid #3b82f6; border-radius: 8px; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); text-align: center; @@ -804,6 +823,581 @@ buttons.forEach(button => { operator: 'and' } } + }, + { + type: 'customHtml', + name: 'Price Calculator', + category: 'Custom HTML', + icon: 'material-symbols:calculate', + description: 'Interactive price calculator with real-time updates', + defaultProps: { + label: 'Price Calculator', + name: 'price_calculator', + help: 'Calculate total price based on quantity and unit price', + htmlContent: `
+

Price Calculator

+
+
+ + +
+
+ + +
+
+ +
$10.00
+
+
+
+ + +
+
`, + cssContent: `.price-calculator { + padding: 20px; + border-radius: 8px; + background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); + font-family: Arial, sans-serif; +} + +.price-calculator h3 { + color: #059669; + margin-bottom: 15px; + text-align: center; +} + +.calculator-grid { + display: grid; + gap: 15px; + margin-bottom: 15px; +} + +.input-group, .result-group { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; +} + +.calc-input { + width: 120px; + padding: 6px 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 14px; +} + +.total-display { + font-weight: bold; + font-size: 18px; + color: #059669; + background: #f0fdf4; + padding: 6px 12px; + border-radius: 4px; + border: 1px solid #10b981; +} + +.discount-section { + border-top: 1px solid #d1d5db; + padding-top: 15px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.discount-display { + font-weight: bold; + color: #dc2626; + background: #fef2f2; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid #fca5a5; +}`, + jsContent: `// Price Calculator JavaScript +console.log('Price calculator initialized'); + +// Get form field values +const quantity = this.getValue('quantity') || 1; +const unitPrice = this.getValue('unit_price') || 10.00; + +// Initialize calculator +function updateCalculator() { + const quantityInput = this.element.querySelector('#quantity'); + const unitPriceInput = this.element.querySelector('#unit-price'); + const totalDisplay = this.element.querySelector('#total-price'); + const discountCheckbox = this.element.querySelector('#discount-checkbox'); + const discountDisplay = this.element.querySelector('#discount-amount'); + + // Set initial values from form data + if (quantityInput) quantityInput.value = quantity; + if (unitPriceInput) unitPriceInput.value = unitPrice; + + function calculateTotal() { + const qty = parseFloat(quantityInput.value) || 0; + const price = parseFloat(unitPriceInput.value) || 0; + const subtotal = qty * price; + const discount = discountCheckbox.checked ? subtotal * 0.1 : 0; + const total = subtotal - discount; + + // Update displays + totalDisplay.textContent = '$' + total.toFixed(2); + discountDisplay.textContent = '-$' + discount.toFixed(2); + discountDisplay.style.display = discountCheckbox.checked ? 'block' : 'none'; + + // Update form fields + this.setValue('quantity', qty); + this.setValue('unit_price', price); + this.setValue('total_price', total); + this.setValue('discount_applied', discountCheckbox.checked); + } + + // Add event listeners + quantityInput.addEventListener('input', calculateTotal.bind(this)); + unitPriceInput.addEventListener('input', calculateTotal.bind(this)); + discountCheckbox.addEventListener('change', calculateTotal.bind(this)); + + // Initial calculation + calculateTotal.call(this); +} + +// Initialize when component loads +updateCalculator.call(this);`, + allowScripts: true, + previewMode: 'advanced', + width: '100%', + gridColumn: 'span 12', + showInPreview: true, + conditionalLogic: { + enabled: false, + conditions: [], + action: 'show', + operator: 'and' + } + } + }, + { + type: 'customHtml', + name: 'Progress Tracker', + category: 'Custom HTML', + icon: 'material-symbols:trending-up', + description: 'Visual progress tracking with dynamic updates', + defaultProps: { + label: 'Progress Tracker', + name: 'progress_tracker', + help: 'Track completion progress with visual indicators', + htmlContent: `
+

Application Progress

+
+
+
1
+
Personal Info
+
Completed
+
+
+
2
+
Documents
+
In Progress
+
+
+
3
+
Review
+
Pending
+
+
+
4
+
Submit
+
Pending
+
+
+
+
+
+
+ 25% Complete +
+
`, + cssContent: `.progress-tracker { + padding: 20px; + border-radius: 8px; + background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%); + font-family: Arial, sans-serif; +} + +.progress-tracker h3 { + color: #7c3aed; + margin-bottom: 20px; + text-align: center; +} + +.progress-steps { + display: flex; + justify-content: space-between; + margin-bottom: 20px; + position: relative; +} + +.progress-steps::before { + content: ''; + position: absolute; + top: 15px; + left: 0; + right: 0; + height: 2px; + background: #e5e7eb; + z-index: 1; +} + +.step { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + z-index: 2; +} + +.step-number { + width: 30px; + height: 30px; + border-radius: 50%; + background: #e5e7eb; + color: #6b7280; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + margin-bottom: 8px; + transition: all 0.3s ease; +} + +.step.completed .step-number { + background: #10b981; + color: white; +} + +.step.current .step-number { + background: #3b82f6; + color: white; + animation: pulse 2s infinite; +} + +.step-label { + font-size: 12px; + font-weight: 500; + color: #374151; + text-align: center; + margin-bottom: 4px; +} + +.step-status { + font-size: 10px; + color: #6b7280; + text-transform: uppercase; +} + +.progress-bar { + height: 8px; + background: #e5e7eb; + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #10b981, #3b82f6); + width: 25%; + transition: width 0.5s ease; +} + +.progress-text { + text-align: center; + font-size: 14px; + color: #6b7280; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +}`, + jsContent: `// Progress Tracker JavaScript +console.log('Progress tracker initialized'); + +// Get current step from form data +const currentStep = this.getValue('current_step') || 1; + +function updateProgress() { + const steps = this.element.querySelectorAll('.step'); + const progressFill = this.element.querySelector('#progress-fill'); + const progressPercentage = this.element.querySelector('#progress-percentage'); + + // Update step states + steps.forEach((step, index) => { + const stepNum = index + 1; + step.classList.remove('completed', 'current'); + + if (stepNum < currentStep) { + step.classList.add('completed'); + } else if (stepNum === currentStep) { + step.classList.add('current'); + } + }); + + // Update progress bar + const percentage = (currentStep / steps.length) * 100; + progressFill.style.width = percentage + '%'; + progressPercentage.textContent = Math.round(percentage) + '%'; + + // Update form data + this.setValue('progress_percentage', percentage); +} + +// Add click handlers to steps +const steps = this.element.querySelectorAll('.step'); +steps.forEach((step, index) => { + step.addEventListener('click', () => { + const newStep = index + 1; + this.setValue('current_step', newStep); + updateProgress.call(this); + }); +}); + +// Initialize progress +updateProgress.call(this);`, + allowScripts: true, + previewMode: 'advanced', + width: '100%', + gridColumn: 'span 12', + showInPreview: true, + conditionalLogic: { + enabled: false, + conditions: [], + action: 'show', + operator: 'and' + } + } + }, + { + type: 'customHtml', + name: 'File Upload Preview', + category: 'Custom HTML', + icon: 'material-symbols:upload-file', + description: 'Custom file upload with preview and validation', + defaultProps: { + label: 'File Upload Preview', + name: 'file_upload_preview', + help: 'Upload files with preview and validation', + htmlContent: `
+

Document Upload

+
+
📁
+

Drag and drop files here or click to browse

+ +
+
+ +
+
+
`, + cssContent: `.file-upload-area { + padding: 20px; + border-radius: 8px; + background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); + font-family: Arial, sans-serif; +} + +.file-upload-area h3 { + color: #d97706; + margin-bottom: 15px; + text-align: center; +} + +.upload-zone { + border: 2px dashed #f59e0b; + border-radius: 8px; + padding: 30px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: white; +} + +.upload-zone:hover { + border-color: #d97706; + background: #fffbeb; +} + +.upload-zone.dragover { + border-color: #10b981; + background: #f0fdf4; +} + +.upload-icon { + font-size: 48px; + margin-bottom: 10px; +} + +.file-list { + margin-top: 15px; +} + +.file-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: white; + border: 1px solid #e5e7eb; + border-radius: 4px; + margin-bottom: 8px; +} + +.file-name { + font-weight: 500; + color: #374151; +} + +.file-size { + font-size: 12px; + color: #6b7280; +} + +.file-status { + font-size: 12px; + padding: 2px 6px; + border-radius: 3px; +} + +.file-status.valid { + background: #d1fae5; + color: #065f46; +} + +.file-status.invalid { + background: #fee2e2; + color: #991b1b; +} + +.upload-status { + margin-top: 10px; + padding: 8px; + border-radius: 4px; + font-size: 14px; + text-align: center; +}`, + jsContent: `// File Upload Preview JavaScript +console.log('File upload preview initialized'); + +const uploadZone = this.element.querySelector('#upload-zone'); +const fileInput = this.element.querySelector('#file-input'); +const fileList = this.element.querySelector('#file-list'); +const uploadStatus = this.element.querySelector('#upload-status'); + +const allowedTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'image/jpeg', 'image/png']; +const maxFileSize = 5 * 1024 * 1024; // 5MB + +function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function validateFile(file) { + const errors = []; + + if (!allowedTypes.includes(file.type)) { + errors.push('Invalid file type'); + } + + if (file.size > maxFileSize) { + errors.push('File too large (max 5MB)'); + } + + return errors; +} + +function createFileItem(file) { + const fileItem = document.createElement('div'); + fileItem.className = 'file-item'; + + const errors = validateFile(file); + const isValid = errors.length === 0; + + fileItem.innerHTML = \` +
+
\${file.name}
+
\${formatFileSize(file.size)}
+
+
+ \${isValid ? '✓ Valid' : '✗ ' + errors.join(', ')} +
+ \`; + + return fileItem; +} + +function handleFiles(files) { + fileList.innerHTML = ''; + let validCount = 0; + let totalCount = files.length; + + Array.from(files).forEach(file => { + const fileItem = createFileItem(file); + fileList.appendChild(fileItem); + + if (validateFile(file).length === 0) { + validCount++; + } + }); + + // Update status + uploadStatus.textContent = \`\${validCount} of \${totalCount} files valid\`; + uploadStatus.className = \`upload-status \${validCount === totalCount ? 'valid' : 'invalid'}\`; + + // Update form data + this.setValue('uploaded_files', Array.from(files).map(f => f.name)); + this.setValue('valid_files_count', validCount); + this.setValue('total_files_count', totalCount); +} + +// Event listeners +uploadZone.addEventListener('click', () => fileInput.click()); + +uploadZone.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadZone.classList.add('dragover'); +}); + +uploadZone.addEventListener('dragleave', () => { + uploadZone.classList.remove('dragover'); +}); + +uploadZone.addEventListener('drop', (e) => { + e.preventDefault(); + uploadZone.classList.remove('dragover'); + handleFiles.call(this, e.dataTransfer.files); +}); + +fileInput.addEventListener('change', (e) => { + handleFiles.call(this, e.target.files); +});`, + allowScripts: true, + previewMode: 'advanced', + width: '100%', + gridColumn: 'span 12', + showInPreview: true, + conditionalLogic: { + enabled: false, + conditions: [], + action: 'show', + operator: 'and' + } + } } ]; diff --git a/components/FormBuilderFieldSettingsModal.vue b/components/FormBuilderFieldSettingsModal.vue index 61b6b57..a087629 100644 --- a/components/FormBuilderFieldSettingsModal.vue +++ b/components/FormBuilderFieldSettingsModal.vue @@ -1579,64 +1579,499 @@ if (name && email) { +