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.
This commit is contained in:
parent
f48eee7cdb
commit
917c2acac1
@ -629,20 +629,34 @@
|
|||||||
|
|
||||||
<!-- Custom HTML Component -->
|
<!-- Custom HTML Component -->
|
||||||
<div v-else-if="component.type === 'customHtml'" class="py-2">
|
<div v-else-if="component.type === 'customHtml'" class="py-2">
|
||||||
<FormKit
|
<div class="custom-html-wrapper">
|
||||||
:id="`preview-${component.id}`"
|
<!-- Component Label -->
|
||||||
type="customHtml"
|
<div v-if="component.props.label && component.props.label !== 'Custom HTML Component'" class="mb-2">
|
||||||
:name="component.props.name"
|
<label class="text-sm font-medium text-gray-700">{{ component.props.label }}</label>
|
||||||
:label="component.props.label"
|
</div>
|
||||||
:help="component.props.help"
|
|
||||||
:htmlContent="component.props.htmlContent"
|
<!-- Help Text -->
|
||||||
:cssContent="component.props.cssContent"
|
<div v-if="component.props.help" class="mb-2 text-xs text-gray-600">
|
||||||
:jsContent="component.props.jsContent"
|
{{ component.props.help }}
|
||||||
:allowScripts="component.props.allowScripts"
|
</div>
|
||||||
:previewMode="component.props.previewMode"
|
|
||||||
:showInPreview="component.props.showInPreview"
|
<!-- Custom HTML Content Container -->
|
||||||
:readonly="component.props.readonly || !isPreview"
|
<div
|
||||||
/>
|
:id="`custom-html-${component.id}`"
|
||||||
|
class="custom-html-content"
|
||||||
|
:class="{
|
||||||
|
'readonly-mode': component.props.readonly || !isPreview,
|
||||||
|
'safe-mode': component.props.previewMode === 'safe',
|
||||||
|
'advanced-mode': component.props.previewMode === 'advanced'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Inject scoped CSS -->
|
||||||
|
<div v-if="getScopedCss(component)" v-html="getScopedCss(component)"></div>
|
||||||
|
|
||||||
|
<!-- Render HTML content -->
|
||||||
|
<div v-html="getSafeHtmlContent(component)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unknown Component Type Fallback -->
|
<!-- Unknown Component Type Fallback -->
|
||||||
@ -665,7 +679,7 @@ import { useNuxtApp } from '#app';
|
|||||||
import { useFormBuilderStore } from '~/stores/formBuilder';
|
import { useFormBuilderStore } from '~/stores/formBuilder';
|
||||||
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
|
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
|
||||||
import { safeGetField } from '~/composables/safeGetField';
|
import { safeGetField } from '~/composables/safeGetField';
|
||||||
import { onMounted, onUnmounted, watch, computed } from 'vue';
|
import { onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
|
||||||
import draggable from 'vuedraggable';
|
import draggable from 'vuedraggable';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -1711,6 +1725,163 @@ const getButtonSizeClass = (size) => {
|
|||||||
};
|
};
|
||||||
return sizeClasses[size] || sizeClasses['md'];
|
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 `<style scoped>
|
||||||
|
#${uniqueId} {
|
||||||
|
/* Component container styles */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
${scopedRules}
|
||||||
|
</style>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSafeHtmlContent = (component) => {
|
||||||
|
// Get HTML content from component props
|
||||||
|
const htmlContent = component.props.htmlContent ||
|
||||||
|
// Fallback to the default content from FormBuilderComponents.vue
|
||||||
|
`<div class="custom-component">
|
||||||
|
<h3>Custom HTML Component</h3>
|
||||||
|
<p>Edit this HTML to create your custom design.</p>
|
||||||
|
<button type="button">Click me!</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -2108,4 +2279,46 @@ const getButtonSizeClass = (size) => {
|
|||||||
.custom-button[data-hover-effect="glow"]:hover {
|
.custom-button[data-hover-effect="glow"]:hover {
|
||||||
box-shadow: 0 0 15px rgba(59, 130, 246, 0.5);
|
box-shadow: 0 0 15px rgba(59, 130, 246, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom HTML Component Styles */
|
||||||
|
.custom-html-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-html-content {
|
||||||
|
min-height: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-html-content.readonly-mode {
|
||||||
|
pointer-events: auto; /* Allow interactions in readonly mode for custom HTML */
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-html-content.safe-mode {
|
||||||
|
/* Safe mode styles */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-html-content.advanced-mode {
|
||||||
|
/* Advanced mode styles */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent layout issues with custom HTML */
|
||||||
|
.custom-html-content :deep(*) {
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure images are responsive */
|
||||||
|
.custom-html-content :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent custom HTML from breaking out of container */
|
||||||
|
.custom-html-content {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -115,6 +115,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom HTML Category -->
|
||||||
|
<div class="component-category mb-4">
|
||||||
|
<h3 class="text-gray-700 text-xs font-semibold px-3 mb-2 uppercase tracking-wider">Custom HTML</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-1 px-2">
|
||||||
|
<div
|
||||||
|
v-for="component in getComponentsByCategory('Custom HTML')"
|
||||||
|
:key="component.type"
|
||||||
|
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200"
|
||||||
|
:class="{ 'hidden': !matchesSearch(component) }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, component)"
|
||||||
|
@dragend="onDragEnd($event)"
|
||||||
|
@click="addComponent(component)"
|
||||||
|
>
|
||||||
|
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
|
||||||
|
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -736,7 +756,7 @@ const availableComponents = [
|
|||||||
{
|
{
|
||||||
type: 'customHtml',
|
type: 'customHtml',
|
||||||
name: 'Custom HTML',
|
name: 'Custom HTML',
|
||||||
category: 'Layout',
|
category: 'Custom HTML',
|
||||||
icon: 'material-symbols:code',
|
icon: 'material-symbols:code',
|
||||||
description: 'Custom HTML with CSS and JavaScript',
|
description: 'Custom HTML with CSS and JavaScript',
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
@ -746,11 +766,10 @@ const availableComponents = [
|
|||||||
htmlContent: `<div class="custom-component">
|
htmlContent: `<div class="custom-component">
|
||||||
<h3>Custom HTML Component</h3>
|
<h3>Custom HTML Component</h3>
|
||||||
<p>Edit this HTML to create your custom design.</p>
|
<p>Edit this HTML to create your custom design.</p>
|
||||||
<button onclick="alert('Hello from custom HTML!')">Click me!</button>
|
<button type="button">Click me!</button>
|
||||||
</div>`,
|
</div>`,
|
||||||
cssContent: `.custom-component {
|
cssContent: `.custom-component {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 2px solid #3b82f6;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -804,6 +823,581 @@ buttons.forEach(button => {
|
|||||||
operator: 'and'
|
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: `<div class="price-calculator">
|
||||||
|
<h3>Price Calculator</h3>
|
||||||
|
<div class="calculator-grid">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Quantity:</label>
|
||||||
|
<input type="number" id="quantity" value="1" min="1" class="calc-input">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Unit Price:</label>
|
||||||
|
<input type="number" id="unit-price" value="10.00" step="0.01" class="calc-input">
|
||||||
|
</div>
|
||||||
|
<div class="result-group">
|
||||||
|
<label>Total Price:</label>
|
||||||
|
<div id="total-price" class="total-display">$10.00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="discount-section">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="discount-checkbox"> Apply 10% discount
|
||||||
|
</label>
|
||||||
|
<div id="discount-amount" class="discount-display" style="display: none;">-$1.00</div>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
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: `<div class="progress-tracker">
|
||||||
|
<h3>Application Progress</h3>
|
||||||
|
<div class="progress-steps">
|
||||||
|
<div class="step" data-step="1">
|
||||||
|
<div class="step-number">1</div>
|
||||||
|
<div class="step-label">Personal Info</div>
|
||||||
|
<div class="step-status">Completed</div>
|
||||||
|
</div>
|
||||||
|
<div class="step" data-step="2">
|
||||||
|
<div class="step-number">2</div>
|
||||||
|
<div class="step-label">Documents</div>
|
||||||
|
<div class="step-status">In Progress</div>
|
||||||
|
</div>
|
||||||
|
<div class="step" data-step="3">
|
||||||
|
<div class="step-number">3</div>
|
||||||
|
<div class="step-label">Review</div>
|
||||||
|
<div class="step-status">Pending</div>
|
||||||
|
</div>
|
||||||
|
<div class="step" data-step="4">
|
||||||
|
<div class="step-number">4</div>
|
||||||
|
<div class="step-label">Submit</div>
|
||||||
|
<div class="step-status">Pending</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">
|
||||||
|
<span id="progress-percentage">25%</span> Complete
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
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: `<div class="file-upload-area">
|
||||||
|
<h3>Document Upload</h3>
|
||||||
|
<div class="upload-zone" id="upload-zone">
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<p>Drag and drop files here or click to browse</p>
|
||||||
|
<input type="file" id="file-input" multiple accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" style="display: none;">
|
||||||
|
</div>
|
||||||
|
<div class="file-list" id="file-list">
|
||||||
|
<!-- Files will be listed here -->
|
||||||
|
</div>
|
||||||
|
<div class="upload-status" id="upload-status"></div>
|
||||||
|
</div>`,
|
||||||
|
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 = \`
|
||||||
|
<div>
|
||||||
|
<div class="file-name">\${file.name}</div>
|
||||||
|
<div class="file-size">\${formatFileSize(file.size)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-status \${isValid ? 'valid' : 'invalid'}">
|
||||||
|
\${isValid ? '✓ Valid' : '✗ ' + errors.join(', ')}
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1579,64 +1579,499 @@ if (name && email) {
|
|||||||
<!-- Custom HTML Configuration -->
|
<!-- Custom HTML Configuration -->
|
||||||
<template v-if="component.type === 'customHtml'">
|
<template v-if="component.type === 'customHtml'">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- HTML Content -->
|
<!-- Code Editor Tabs -->
|
||||||
<div class="space-y-4">
|
<div class="code-editor-tabs">
|
||||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">HTML Content</h5>
|
<div class="tabs-header border-b border-gray-200">
|
||||||
|
<div class="flex">
|
||||||
|
<button
|
||||||
|
@click="customHtmlActiveTab = 'html'"
|
||||||
|
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
:class="customHtmlActiveTab === 'html' ? 'text-blue-600 border-b-2 border-blue-500 bg-blue-50' : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Icon name="material-symbols:code" class="w-4 h-4 mr-2" />
|
||||||
|
HTML
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="customHtmlActiveTab = 'css'"
|
||||||
|
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
:class="customHtmlActiveTab === 'css' ? 'text-blue-600 border-b-2 border-blue-500 bg-blue-50' : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Icon name="material-symbols:format-color-fill" class="w-4 h-4 mr-2" />
|
||||||
|
CSS
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="customHtmlActiveTab = 'js'"
|
||||||
|
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
:class="customHtmlActiveTab === 'js' ? 'text-blue-600 border-b-2 border-blue-500 bg-blue-50' : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Icon name="material-symbols:code-blocks" class="w-4 h-4 mr-2" />
|
||||||
|
JavaScript
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="customHtmlActiveTab = 'preview'"
|
||||||
|
class="px-4 py-2 text-sm font-medium transition-colors ml-auto"
|
||||||
|
:class="customHtmlActiveTab === 'preview' ? 'text-green-600 border-b-2 border-green-500 bg-green-50' : 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Icon name="material-symbols:preview" class="w-4 h-4 mr-2" />
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormKit
|
<!-- HTML Tab -->
|
||||||
type="textarea"
|
<div v-show="customHtmlActiveTab === 'html'" class="tab-content py-4">
|
||||||
label="HTML Code"
|
<div class="html-editor-container">
|
||||||
name="htmlContent"
|
<RsCodeMirror
|
||||||
v-model="configModel.htmlContent"
|
v-model="configModel.htmlContent"
|
||||||
help="Enter your custom HTML code here"
|
language="html"
|
||||||
:classes="{
|
height="300px"
|
||||||
outer: 'field-wrapper',
|
class="html-editor border border-gray-200 rounded"
|
||||||
input: 'font-mono text-sm min-h-[120px]'
|
placeholder="<!-- Example 1: Price Calculator -->
|
||||||
}"
|
<div class='price-calculator'>
|
||||||
placeholder="<div>Your custom HTML here...</div>"
|
<h3>Order Calculator</h3>
|
||||||
rows="6"
|
<div class='calc-row'>
|
||||||
/>
|
<label>Quantity:</label>
|
||||||
|
<input type='number' id='qty' value='1' min='1' class='calc-input' placeholder='Enter quantity'>
|
||||||
|
</div>
|
||||||
|
<div class='calc-row'>
|
||||||
|
<label>Unit Price:</label>
|
||||||
|
<input type='number' id='price' value='10.00' step='0.01' class='calc-input' placeholder='Enter price'>
|
||||||
|
</div>
|
||||||
|
<div class='calc-row'>
|
||||||
|
<label>Total:</label>
|
||||||
|
<div id='total' class='total-display'>$10.00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Example 2: Progress Tracker -->
|
||||||
|
<div class='progress-tracker'>
|
||||||
|
<h3>Application Progress</h3>
|
||||||
|
<div class='steps'>
|
||||||
|
<div class='step' data-step='1'>Personal Info</div>
|
||||||
|
<div class='step' data-step='2'>Documents</div>
|
||||||
|
<div class='step' data-step='3'>Review</div>
|
||||||
|
</div>
|
||||||
|
<div class='progress-bar'>
|
||||||
|
<div id='progress-fill' class='progress-fill'></div>
|
||||||
|
</div>
|
||||||
|
<div class='text-center text-sm text-gray-600 mt-3'>
|
||||||
|
<span id='progress-percentage'>33%</span> Complete
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Example 3: File Upload Preview -->
|
||||||
|
<div class='file-upload'>
|
||||||
|
<h3>Document Upload</h3>
|
||||||
|
<div id='drop-zone' class='drop-zone'>
|
||||||
|
<div class='text-4xl mb-3'>📁</div>
|
||||||
|
<p>Drag files here or click to browse</p>
|
||||||
|
<p class='text-xs text-gray-500 mt-2'>Supports PDF, DOC, images up to 5MB</p>
|
||||||
|
<input type='file' id='file-input' multiple accept='.pdf,.doc,.docx,.jpg,.jpeg,.png' style='display: none;'>
|
||||||
|
</div>
|
||||||
|
<div id='file-list' class='file-list'></div>
|
||||||
|
</div>"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 flex items-start">
|
||||||
|
<Icon name="material-symbols:lightbulb-outline" class="w-4 h-4 mr-1 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Create your custom HTML structure. Use standard HTML5 elements and attributes. Use <code class="bg-gray-100 px-1 rounded text-xs">this.getValue('fieldName')</code> and <code class="bg-gray-100 px-1 rounded text-xs">this.setValue('fieldName', value)</code> to interact with form fields.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSS Tab -->
|
||||||
|
<div v-show="customHtmlActiveTab === 'css'" class="tab-content py-4">
|
||||||
|
<div class="css-editor-container">
|
||||||
|
<RsCodeMirror
|
||||||
|
v-model="configModel.cssContent"
|
||||||
|
language="css"
|
||||||
|
height="300px"
|
||||||
|
class="css-editor border border-gray-200 rounded"
|
||||||
|
placeholder="/* Example 1: Price Calculator Styles */
|
||||||
|
.price-calculator {
|
||||||
|
padding: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-calculator h3 {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-input {
|
||||||
|
width: 120px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calc-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6b7280;
|
||||||
|
box-shadow: 0 0 0 3px rgba(107, 114, 128, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-display {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #111827;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Example 2: Progress Tracker Styles */
|
||||||
|
.progress-tracker {
|
||||||
|
padding: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-tracker h3 {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active {
|
||||||
|
background: #6b7280;
|
||||||
|
border-color: #6b7280;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.completed {
|
||||||
|
background: #9ca3af;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #6b7280;
|
||||||
|
width: 33%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Example 3: File Upload Styles */
|
||||||
|
.file-upload {
|
||||||
|
padding: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload h3 {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover {
|
||||||
|
border-color: #6b7280;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.dragover {
|
||||||
|
border-color: #9ca3af;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .valid {
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .invalid {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 500;
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 flex items-start">
|
||||||
|
<Icon name="material-symbols:lightbulb-outline" class="w-4 h-4 mr-1 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Add custom CSS styles. These styles will be automatically scoped to this component only, preventing conflicts with other form elements.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript Tab -->
|
||||||
|
<div v-show="customHtmlActiveTab === 'js'" class="tab-content py-4">
|
||||||
|
<div class="js-editor-container">
|
||||||
|
<RsCodeMirror
|
||||||
|
v-model="configModel.jsContent"
|
||||||
|
language="javascript"
|
||||||
|
height="300px"
|
||||||
|
class="js-editor border border-gray-200 rounded"
|
||||||
|
placeholder="// Example 1: Price Calculator JavaScript
|
||||||
|
console.log('Price calculator initialized');
|
||||||
|
|
||||||
|
function calculateTotal() {
|
||||||
|
const qtyInput = this.element.querySelector('#qty');
|
||||||
|
const priceInput = this.element.querySelector('#price');
|
||||||
|
const totalDisplay = this.element.querySelector('#total');
|
||||||
|
|
||||||
|
if (qtyInput && priceInput && totalDisplay) {
|
||||||
|
const qty = parseFloat(qtyInput.value) || 0;
|
||||||
|
const price = parseFloat(priceInput.value) || 0;
|
||||||
|
const total = qty * price;
|
||||||
|
|
||||||
|
totalDisplay.textContent = '$' + total.toFixed(2);
|
||||||
|
|
||||||
|
// Update form fields
|
||||||
|
this.setValue('quantity', qty);
|
||||||
|
this.setValue('unit_price', price);
|
||||||
|
this.setValue('total_price', total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners for real-time calculation
|
||||||
|
const qtyInput = this.element.querySelector('#qty');
|
||||||
|
const priceInput = this.element.querySelector('#price');
|
||||||
|
|
||||||
|
if (qtyInput) qtyInput.addEventListener('input', calculateTotal.bind(this));
|
||||||
|
if (priceInput) priceInput.addEventListener('input', calculateTotal.bind(this));
|
||||||
|
|
||||||
|
// Example 2: Progress Tracker JavaScript
|
||||||
|
function updateProgress() {
|
||||||
|
const steps = this.element.querySelectorAll('.step');
|
||||||
|
const progressFill = this.element.querySelector('#progress-fill');
|
||||||
|
|
||||||
|
// Get current step from form data
|
||||||
|
const currentStep = this.getValue('current_step') || 1;
|
||||||
|
|
||||||
|
steps.forEach((step, index) => {
|
||||||
|
const stepNum = index + 1;
|
||||||
|
step.classList.remove('active');
|
||||||
|
|
||||||
|
if (stepNum <= currentStep) {
|
||||||
|
step.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (progressFill) {
|
||||||
|
const percentage = (currentStep / steps.length) * 100;
|
||||||
|
progressFill.style.width = 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Example 3: File Upload JavaScript
|
||||||
|
function setupFileUpload() {
|
||||||
|
const dropZone = this.element.querySelector('#drop-zone');
|
||||||
|
const fileInput = this.element.querySelector('#file-input');
|
||||||
|
const fileList = this.element.querySelector('#file-list');
|
||||||
|
|
||||||
|
if (!dropZone || !fileInput || !fileList) return;
|
||||||
|
|
||||||
|
function handleFiles(files) {
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
let validCount = 0;
|
||||||
|
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
const fileItem = document.createElement('div');
|
||||||
|
fileItem.className = 'file-item';
|
||||||
|
|
||||||
|
// Validate file (example: only images and PDFs)
|
||||||
|
const isValid = file.type.startsWith('image/') || file.type === 'application/pdf';
|
||||||
|
if (isValid) validCount++;
|
||||||
|
|
||||||
|
fileItem.innerHTML = \`
|
||||||
|
<span>\${file.name}</span>
|
||||||
|
<span class='\${isValid ? 'valid' : 'invalid'}'>\${isValid ? '✓' : '✗'}</span>
|
||||||
|
\`;
|
||||||
|
|
||||||
|
fileList.appendChild(fileItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update form data
|
||||||
|
this.setValue('uploaded_files', Array.from(files).map(f => f.name));
|
||||||
|
this.setValue('valid_files_count', validCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragleave', () => {
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
handleFiles.call(this, e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
handleFiles.call(this, e.target.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize components
|
||||||
|
if (this.element.querySelector('.price-calculator')) {
|
||||||
|
calculateTotal.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.element.querySelector('.progress-tracker')) {
|
||||||
|
updateProgress.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.element.querySelector('.file-upload')) {
|
||||||
|
setupFileUpload.call(this);
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 flex items-start">
|
||||||
|
<Icon name="material-symbols:lightbulb-outline" class="w-4 h-4 mr-1 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Add interactive JavaScript functionality. Use <code class="bg-gray-100 px-1 rounded text-xs">this.element</code> to access the component, and the helper functions for form interaction. Scripts run in {{ configModel.previewMode === 'safe' ? 'Safe Mode (disabled)' : 'Advanced Mode' }}.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Preview Tab -->
|
||||||
|
<div v-show="customHtmlActiveTab === 'preview'" class="tab-content py-4">
|
||||||
|
<div class="preview-section">
|
||||||
|
<div class="preview-container border border-gray-200 rounded-lg p-4 bg-white min-h-[200px] max-h-[400px] overflow-auto">
|
||||||
|
<div v-if="configModel.htmlContent" class="custom-html-preview">
|
||||||
|
<div v-html="getFullHtmlPreview(configModel.htmlContent, configModel.cssContent)"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-500 text-center py-12">
|
||||||
|
<Icon name="material-symbols:code" class="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p class="text-sm">No HTML content to preview</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Switch to the HTML tab to start coding</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CSS Content -->
|
<!-- Component Settings -->
|
||||||
<div class="space-y-4">
|
|
||||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">CSS Styles</h5>
|
|
||||||
|
|
||||||
<FormKit
|
|
||||||
type="textarea"
|
|
||||||
label="CSS Code"
|
|
||||||
name="cssContent"
|
|
||||||
v-model="configModel.cssContent"
|
|
||||||
help="Enter your custom CSS styles here"
|
|
||||||
:classes="{
|
|
||||||
outer: 'field-wrapper',
|
|
||||||
input: 'font-mono text-sm min-h-[100px]'
|
|
||||||
}"
|
|
||||||
placeholder=".my-class { color: blue; }"
|
|
||||||
rows="5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript Content -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">JavaScript Code</h5>
|
|
||||||
|
|
||||||
<FormKit
|
|
||||||
type="textarea"
|
|
||||||
label="JavaScript Code"
|
|
||||||
name="jsContent"
|
|
||||||
v-model="configModel.jsContent"
|
|
||||||
help="Enter your custom JavaScript code here"
|
|
||||||
:classes="{
|
|
||||||
outer: 'field-wrapper',
|
|
||||||
input: 'font-mono text-sm min-h-[100px]'
|
|
||||||
}"
|
|
||||||
placeholder="console.log('Hello from custom HTML!');"
|
|
||||||
rows="5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Component Settings</h5>
|
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Component Settings</h5>
|
||||||
|
|
||||||
@ -1674,18 +2109,21 @@ if (name && email) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview Area -->
|
<!-- Content Statistics -->
|
||||||
<div class="space-y-4">
|
<div class="p-4 bg-gray-50 rounded-lg border">
|
||||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Live Preview</h5>
|
<h5 class="text-sm font-medium text-gray-700 mb-2">Content Statistics</h5>
|
||||||
|
<div class="flex items-center space-x-6 text-xs text-gray-600">
|
||||||
<div class="preview-container border border-gray-200 rounded-lg p-4 bg-gray-50 min-h-[100px]">
|
<div class="flex items-center">
|
||||||
<div v-if="configModel.htmlContent" class="custom-html-preview" ref="previewContainer">
|
<Icon name="material-symbols:html" class="w-4 h-4 mr-1" />
|
||||||
<!-- Safe preview - just show the HTML without scripts -->
|
HTML: {{ (configModel.htmlContent || '').length }} characters
|
||||||
<div v-html="getFullHtmlPreview(configModel.htmlContent, configModel.cssContent)"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-gray-500 text-center py-8">
|
<div class="flex items-center">
|
||||||
<Icon name="material-symbols:code" class="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
<Icon name="material-symbols:css" class="w-4 h-4 mr-1" />
|
||||||
<p class="text-sm">No HTML content to preview</p>
|
CSS: {{ (configModel.cssContent || '').length }} characters
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Icon name="material-symbols:javascript" class="w-4 h-4 mr-1" />
|
||||||
|
JavaScript: {{ (configModel.jsContent || '').length }} characters
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2086,6 +2524,7 @@ if (name && email) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</RsModal>
|
</RsModal>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -2106,6 +2545,7 @@ const isOpen = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const activeTab = ref('basic')
|
const activeTab = ref('basic')
|
||||||
|
const customHtmlActiveTab = ref('html')
|
||||||
const configModel = ref({
|
const configModel = ref({
|
||||||
conditionalLogic: {
|
conditionalLogic: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@ -2365,6 +2805,14 @@ const getFullHtmlPreview = (htmlContent, cssContent) => {
|
|||||||
return safeHtml
|
return safeHtml
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom HTML tab integration
|
||||||
|
const getTotalContentLength = () => {
|
||||||
|
const html = configModel.value.htmlContent || '';
|
||||||
|
const css = configModel.value.cssContent || '';
|
||||||
|
const js = configModel.value.jsContent || '';
|
||||||
|
return html.length + css.length + js.length;
|
||||||
|
}
|
||||||
|
|
||||||
// Published processes for button linking
|
// Published processes for button linking
|
||||||
const publishedProcesses = ref([])
|
const publishedProcesses = ref([])
|
||||||
|
|
||||||
|
@ -2681,8 +2681,6 @@ const handleFormKitInput = (formData, node) => {
|
|||||||
// Watch for changes in previewFormData to trigger FormScriptEngine
|
// Watch for changes in previewFormData to trigger FormScriptEngine
|
||||||
// Only update store if data actually changed to prevent recursion
|
// Only update store if data actually changed to prevent recursion
|
||||||
watch(() => previewFormData.value, (newData, oldData) => {
|
watch(() => previewFormData.value, (newData, oldData) => {
|
||||||
if (!isPreview.value) return; // Only in preview mode
|
|
||||||
|
|
||||||
// Check if data actually changed to prevent unnecessary updates
|
// Check if data actually changed to prevent unnecessary updates
|
||||||
const newDataStr = JSON.stringify(newData);
|
const newDataStr = JSON.stringify(newData);
|
||||||
const oldDataStr = JSON.stringify(oldData);
|
const oldDataStr = JSON.stringify(oldData);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user