Add Info Display Component and Enhance Form Settings
- Introduced a new 'Info Display' component for displaying read-only information in a key-value format, with customizable layouts and styles. - Removed the 'Repeater' and 'Group' components from the available components list to streamline options. - Enhanced the Form Builder configuration to support custom scripts, CSS, and event handling, allowing for more dynamic form behavior. - Added a modal for Form Settings, enabling users to manage form properties, custom scripts, and event triggers effectively. - Updated the database schema to accommodate new fields for custom scripts, CSS, and event configurations.
This commit is contained in:
parent
48309d716f
commit
9ea4e18672
@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div class="component-preview" :style="componentStyle">
|
||||
<div
|
||||
class="component-preview form-field"
|
||||
:style="componentStyle"
|
||||
:data-name="component.props.name"
|
||||
:data-type="component.type"
|
||||
>
|
||||
<!-- Basic Input Types (including radio and checkbox) -->
|
||||
<FormKit
|
||||
v-if="isInputType"
|
||||
@ -26,28 +31,7 @@
|
||||
:class="{ 'canvas-component': isPreview }"
|
||||
/>
|
||||
|
||||
<!-- Group Component -->
|
||||
<div v-else-if="component.type === 'group'" class="py-2 border border-gray-200 p-3 rounded">
|
||||
<div class="text-sm font-medium mb-2">{{ component.props.label || 'Group' }}</div>
|
||||
<div class="text-xs text-gray-500">{{ component.props.help || 'A group can contain other form elements' }}</div>
|
||||
<div class="mt-3 p-3 bg-gray-50 border border-dashed border-gray-300 rounded text-center text-xs text-gray-400">
|
||||
Empty group - add components here in the final form
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repeater Component -->
|
||||
<div v-else-if="component.type === 'repeater'" class="py-2 border border-gray-200 p-3 rounded">
|
||||
<div class="text-sm font-medium mb-2">{{ component.props.label || 'Repeater' }}</div>
|
||||
<div class="text-xs text-gray-500">{{ component.props.help || 'A repeater allows adding multiple instances of fields' }}</div>
|
||||
<div class="mt-3 p-3 bg-gray-50 border border-dashed border-gray-300 rounded text-center text-xs text-gray-400">
|
||||
Empty repeater - add components here in the final form
|
||||
</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button class="text-xs px-2 py-1 bg-gray-100 text-gray-500 rounded disabled:opacity-50" disabled>
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Heading -->
|
||||
<div v-else-if="component.type === 'heading'" class="py-2">
|
||||
@ -69,6 +53,52 @@
|
||||
<p class="text-gray-600">{{ component.props.value || 'Paragraph text goes here' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Information Display -->
|
||||
<div v-else-if="component.type === 'info-display'" class="py-2">
|
||||
<div
|
||||
class="info-display-container rounded"
|
||||
:style="{
|
||||
backgroundColor: component.props.backgroundColor || '#f9fafb',
|
||||
border: component.props.showBorder ? '1px solid #e5e7eb' : 'none'
|
||||
}"
|
||||
>
|
||||
<!-- Title -->
|
||||
<div v-if="component.props.title" class="p-3 border-b border-gray-200 bg-white">
|
||||
<h4 class="text-base font-medium text-gray-800">{{ component.props.title }}</h4>
|
||||
</div>
|
||||
|
||||
<!-- Fields Display -->
|
||||
<div class="p-4">
|
||||
<div
|
||||
v-if="component.props.layout === 'grid'"
|
||||
class="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<div v-for="(field, index) in component.props.fields" :key="index" class="field-item">
|
||||
<dt class="text-sm font-medium text-gray-600">{{ field.label }}</dt>
|
||||
<dd class="text-sm text-gray-900 mt-1">{{ field.value }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="component.props.layout === 'horizontal'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div v-for="(field, index) in component.props.fields" :key="index" class="flex justify-between items-center">
|
||||
<dt class="text-sm font-medium text-gray-600">{{ field.label }}:</dt>
|
||||
<dd class="text-sm text-gray-900">{{ field.value }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="(field, index) in component.props.fields" :key="index" class="field-item">
|
||||
<dt class="text-sm font-medium text-gray-600">{{ field.label }}</dt>
|
||||
<dd class="text-sm text-gray-900 mt-1">{{ field.value }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div v-else-if="component.type === 'divider'" class="py-4">
|
||||
<hr class="border-gray-200" />
|
||||
|
@ -294,35 +294,26 @@ const availableComponents = [
|
||||
validation: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'repeater',
|
||||
name: 'Repeater',
|
||||
category: 'Advanced',
|
||||
icon: 'material-symbols:add-box-outline',
|
||||
description: 'Repeatable group of fields',
|
||||
defaultProps: {
|
||||
type: 'repeater',
|
||||
label: 'Repeater Section',
|
||||
name: 'repeater',
|
||||
help: 'Add multiple items',
|
||||
max: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
name: 'Group',
|
||||
category: 'Advanced',
|
||||
icon: 'material-symbols:folder-outline',
|
||||
description: 'Group of related fields',
|
||||
defaultProps: {
|
||||
type: 'group',
|
||||
label: 'Field Group',
|
||||
name: 'group',
|
||||
help: 'Group of related fields'
|
||||
}
|
||||
},
|
||||
|
||||
// Layout
|
||||
{
|
||||
type: 'info-display',
|
||||
name: 'Info Display',
|
||||
category: 'Layout',
|
||||
icon: 'material-symbols:info-outline',
|
||||
description: 'Display read-only information in key-value format',
|
||||
defaultProps: {
|
||||
title: 'Information',
|
||||
fields: [
|
||||
{ label: 'Customer Name', value: 'John Doe', key: 'customer_name' },
|
||||
{ label: 'Email', value: 'john@example.com', key: 'customer_email' },
|
||||
{ label: 'Phone', value: '+1-234-567-8900', key: 'customer_phone' }
|
||||
],
|
||||
layout: 'vertical', // vertical, horizontal, grid
|
||||
showBorder: true,
|
||||
backgroundColor: '#f9fafb'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
name: 'Heading',
|
||||
|
@ -174,16 +174,105 @@
|
||||
help="File types, e.g. '.jpg,.png,image/*'"
|
||||
/>
|
||||
|
||||
<!-- Max items for repeater -->
|
||||
<FormKit
|
||||
v-if="showField('max')"
|
||||
type="number"
|
||||
label="Maximum Items"
|
||||
name="max"
|
||||
v-model="configModel.max"
|
||||
help="Maximum number of repeatable items"
|
||||
min="1"
|
||||
/>
|
||||
|
||||
|
||||
<!-- Information Display Configuration -->
|
||||
<template v-if="component.type === 'info-display'">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Title"
|
||||
name="title"
|
||||
v-model="configModel.title"
|
||||
help="Title displayed at the top"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Layout"
|
||||
name="layout"
|
||||
v-model="configModel.layout"
|
||||
:options="[
|
||||
{ label: 'Vertical (Label above value)', value: 'vertical' },
|
||||
{ label: 'Horizontal (Label: Value)', value: 'horizontal' },
|
||||
{ label: 'Grid (2 columns)', value: 'grid' }
|
||||
]"
|
||||
help="How to display the information fields"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
label="Show Border"
|
||||
name="showBorder"
|
||||
v-model="configModel.showBorder"
|
||||
help="Show border around the information display"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
label="Background Color"
|
||||
name="backgroundColor"
|
||||
v-model="configModel.backgroundColor"
|
||||
help="Background color"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Information Fields Management -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="text-sm font-medium">Information Fields</label>
|
||||
<button
|
||||
class="text-xs text-blue-600 hover:text-blue-700 flex items-center"
|
||||
@click="addInfoField"
|
||||
>
|
||||
<Icon name="material-symbols:add-circle-outline" class="w-3.5 h-3.5 mr-0.5" />
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border rounded bg-gray-50 divide-y max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-for="(field, index) in configModel.fields"
|
||||
:key="index"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2 mb-2">
|
||||
<FormKit
|
||||
type="text"
|
||||
placeholder="Label (e.g., Customer Name)"
|
||||
v-model="field.label"
|
||||
:classes="{ outer: 'mb-0' }"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
placeholder="Key (e.g., customer_name)"
|
||||
v-model="field.key"
|
||||
:classes="{ outer: 'mb-0' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<FormKit
|
||||
type="text"
|
||||
placeholder="Value (e.g., John Doe)"
|
||||
v-model="field.value"
|
||||
:classes="{ outer: 'mb-0 flex-1' }"
|
||||
/>
|
||||
<button
|
||||
class="ml-2 p-1 text-gray-400 hover:text-red-500 rounded"
|
||||
@click="removeInfoField(index)"
|
||||
title="Remove field"
|
||||
>
|
||||
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!configModel.fields || configModel.fields.length === 0" class="p-3 text-center text-gray-500 text-xs">
|
||||
No information fields added yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Validation Tab -->
|
||||
@ -514,12 +603,16 @@ const showField = (fieldName) => {
|
||||
return ['select', 'radio', 'checkbox'].includes(componentType);
|
||||
case 'accept':
|
||||
return componentType === 'file';
|
||||
case 'max':
|
||||
return componentType === 'repeater';
|
||||
case 'id':
|
||||
return true; // Always show component ID in advanced tab
|
||||
case 'width':
|
||||
return true; // Always show width in basic tab
|
||||
case 'title':
|
||||
return componentType === 'info-display';
|
||||
case 'layout':
|
||||
case 'showBorder':
|
||||
case 'fields':
|
||||
return componentType === 'info-display';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@ -542,6 +635,24 @@ const removeOption = (index) => {
|
||||
configModel.value.options.splice(index, 1);
|
||||
};
|
||||
|
||||
// Add a new information field for info-display component
|
||||
const addInfoField = () => {
|
||||
if (!configModel.value.fields) {
|
||||
configModel.value.fields = [];
|
||||
}
|
||||
|
||||
configModel.value.fields.push({
|
||||
label: `Field ${configModel.value.fields.length + 1}`,
|
||||
value: 'Value',
|
||||
key: `field_${configModel.value.fields.length + 1}`
|
||||
});
|
||||
};
|
||||
|
||||
// Remove an information field from info-display component
|
||||
const removeInfoField = (index) => {
|
||||
configModel.value.fields.splice(index, 1);
|
||||
};
|
||||
|
||||
// Inside the <script setup> section
|
||||
// Add width to fieldsToShow
|
||||
const fieldsToShow = {
|
||||
@ -564,13 +675,12 @@ const fieldsToShow = {
|
||||
|
||||
// Advanced
|
||||
file: ['label', 'name', 'help', 'accept', 'width'],
|
||||
repeater: ['label', 'name', 'help', 'max', 'width'],
|
||||
group: ['label', 'name', 'help', 'width'],
|
||||
|
||||
// Layout elements
|
||||
heading: ['value', 'level', 'width'],
|
||||
paragraph: ['value', 'width'],
|
||||
divider: ['width']
|
||||
divider: ['width'],
|
||||
'info-display': ['title', 'layout', 'showBorder', 'backgroundColor', 'fields', 'width']
|
||||
};
|
||||
|
||||
// Add these methods
|
||||
@ -585,7 +695,7 @@ const getComponentWidthPercent = () => {
|
||||
|
||||
// Computed property to determine if validation tab should be shown
|
||||
const showValidationTab = computed(() => {
|
||||
const nonValidationComponents = ['section', 'page', 'wizard', 'checkbox', 'repeater', 'group'];
|
||||
const nonValidationComponents = ['heading', 'paragraph', 'divider', 'info-display'];
|
||||
return !nonValidationComponents.includes(props.component.type);
|
||||
});
|
||||
|
||||
|
271
components/FormScriptEngine.vue
Normal file
271
components/FormScriptEngine.vue
Normal file
@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Custom CSS Injection -->
|
||||
<component :is="'style'" v-if="customCSS" scoped>
|
||||
{{ customCSS }}
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
formData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
customScript: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
customCSS: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
formEvents: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
onLoad: true,
|
||||
onFieldChange: true,
|
||||
onSubmit: false,
|
||||
onValidation: false
|
||||
})
|
||||
},
|
||||
scriptMode: {
|
||||
type: String,
|
||||
default: 'safe' // 'safe' or 'advanced'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['field-change', 'form-submit', 'field-validate']);
|
||||
|
||||
// Internal state
|
||||
const scriptContext = ref(null);
|
||||
const fieldChangeHandlers = ref(new Map());
|
||||
const isScriptInitialized = ref(false);
|
||||
|
||||
// Create safe execution context
|
||||
const createScriptContext = () => {
|
||||
const context = {
|
||||
// Form field interaction methods
|
||||
getField: (fieldName) => {
|
||||
return props.formData[fieldName];
|
||||
},
|
||||
|
||||
setField: (fieldName, value) => {
|
||||
emit('field-change', { fieldName, value });
|
||||
},
|
||||
|
||||
hideField: (fieldName) => {
|
||||
const fieldElement = document.querySelector(`[data-name="${fieldName}"]`);
|
||||
if (fieldElement) {
|
||||
fieldElement.style.display = 'none';
|
||||
fieldElement.setAttribute('data-hidden', 'true');
|
||||
}
|
||||
},
|
||||
|
||||
showField: (fieldName) => {
|
||||
const fieldElement = document.querySelector(`[data-name="${fieldName}"]`);
|
||||
if (fieldElement) {
|
||||
fieldElement.style.display = '';
|
||||
fieldElement.removeAttribute('data-hidden');
|
||||
}
|
||||
},
|
||||
|
||||
disableField: (fieldName) => {
|
||||
const fieldElement = document.querySelector(`[data-name="${fieldName}"] input, [data-name="${fieldName}"] select, [data-name="${fieldName}"] textarea`);
|
||||
if (fieldElement) {
|
||||
fieldElement.disabled = true;
|
||||
fieldElement.closest('[data-name]')?.setAttribute('data-disabled', 'true');
|
||||
}
|
||||
},
|
||||
|
||||
enableField: (fieldName) => {
|
||||
const fieldElement = document.querySelector(`[data-name="${fieldName}"] input, [data-name="${fieldName}"] select, [data-name="${fieldName}"] textarea`);
|
||||
if (fieldElement) {
|
||||
fieldElement.disabled = false;
|
||||
fieldElement.closest('[data-name]')?.removeAttribute('data-disabled');
|
||||
}
|
||||
},
|
||||
|
||||
validateField: (fieldName) => {
|
||||
emit('field-validate', { fieldName });
|
||||
},
|
||||
|
||||
getAllFieldValues: () => {
|
||||
return { ...props.formData };
|
||||
},
|
||||
|
||||
onFieldChange: (fieldNames, callback) => {
|
||||
const fields = Array.isArray(fieldNames) ? fieldNames : [fieldNames];
|
||||
fields.forEach(fieldName => {
|
||||
if (!fieldChangeHandlers.value.has(fieldName)) {
|
||||
fieldChangeHandlers.value.set(fieldName, []);
|
||||
}
|
||||
fieldChangeHandlers.value.get(fieldName).push(callback);
|
||||
});
|
||||
},
|
||||
|
||||
// Utility functions
|
||||
console: {
|
||||
log: (...args) => console.log('[Form Script]', ...args),
|
||||
warn: (...args) => console.warn('[Form Script]', ...args),
|
||||
error: (...args) => console.error('[Form Script]', ...args)
|
||||
},
|
||||
|
||||
// Math utilities
|
||||
Math: Math,
|
||||
|
||||
// Date utilities
|
||||
Date: Date,
|
||||
|
||||
// Safe DOM manipulation (limited)
|
||||
querySelector: (selector) => {
|
||||
// Only allow querying within form container
|
||||
const formContainer = document.querySelector('.form-container');
|
||||
return formContainer ? formContainer.querySelector(selector) : null;
|
||||
},
|
||||
|
||||
// Array utilities
|
||||
Array: Array,
|
||||
|
||||
// String utilities
|
||||
String: String,
|
||||
|
||||
// Number utilities
|
||||
Number: Number
|
||||
};
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
// Safe script execution
|
||||
const executeScript = (script, context) => {
|
||||
if (!script || !script.trim()) return;
|
||||
|
||||
try {
|
||||
if (props.scriptMode === 'safe') {
|
||||
// Create a safe execution environment
|
||||
const allowedGlobals = Object.keys(context);
|
||||
const globalProxy = new Proxy({}, {
|
||||
get(target, prop) {
|
||||
if (allowedGlobals.includes(prop)) {
|
||||
return context[prop];
|
||||
}
|
||||
throw new Error(`Access to '${prop}' is not allowed in safe mode`);
|
||||
},
|
||||
set() {
|
||||
throw new Error('Setting global variables is not allowed in safe mode');
|
||||
}
|
||||
});
|
||||
|
||||
// Create function with limited scope
|
||||
const scriptFunction = new Function(
|
||||
...allowedGlobals,
|
||||
`
|
||||
"use strict";
|
||||
${script}
|
||||
`
|
||||
);
|
||||
|
||||
// Execute with only allowed context
|
||||
scriptFunction.apply(globalProxy, allowedGlobals.map(key => context[key]));
|
||||
} else {
|
||||
// Advanced mode - more permissive but still sandboxed
|
||||
const scriptFunction = new Function('context', `
|
||||
"use strict";
|
||||
with(context) {
|
||||
${script}
|
||||
}
|
||||
`);
|
||||
|
||||
scriptFunction.call(null, context);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Script execution error:', error);
|
||||
// Could emit an error event here for user feedback
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize script engine
|
||||
const initializeScript = async () => {
|
||||
if (!props.customScript || isScriptInitialized.value) return;
|
||||
|
||||
await nextTick(); // Ensure DOM is ready
|
||||
|
||||
scriptContext.value = createScriptContext();
|
||||
|
||||
if (props.formEvents.onLoad) {
|
||||
executeScript(props.customScript, scriptContext.value);
|
||||
}
|
||||
|
||||
isScriptInitialized.value = true;
|
||||
};
|
||||
|
||||
// Handle field changes
|
||||
const handleFieldChange = (fieldName, newValue, oldValue) => {
|
||||
if (!fieldChangeHandlers.value.has(fieldName)) return;
|
||||
|
||||
const handlers = fieldChangeHandlers.value.get(fieldName);
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
if (typeof handler === 'function') {
|
||||
handler.call(scriptContext.value, newValue, oldValue);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in field change handler for '${fieldName}':`, error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Watch for form data changes
|
||||
watch(() => props.formData, (newData, oldData) => {
|
||||
if (!isScriptInitialized.value || !props.formEvents.onFieldChange) return;
|
||||
|
||||
// Compare and trigger handlers for changed fields
|
||||
Object.keys(newData).forEach(fieldName => {
|
||||
if (newData[fieldName] !== oldData?.[fieldName]) {
|
||||
handleFieldChange(fieldName, newData[fieldName], oldData?.[fieldName]);
|
||||
}
|
||||
});
|
||||
}, { deep: true });
|
||||
|
||||
// Watch for script changes and reinitialize
|
||||
watch(() => props.customScript, () => {
|
||||
if (isScriptInitialized.value) {
|
||||
isScriptInitialized.value = false;
|
||||
fieldChangeHandlers.value.clear();
|
||||
initializeScript();
|
||||
}
|
||||
});
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
initializeScript();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
fieldChangeHandlers.value.clear();
|
||||
isScriptInitialized.value = false;
|
||||
});
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
executeScript: (script) => {
|
||||
if (scriptContext.value) {
|
||||
executeScript(script, scriptContext.value);
|
||||
}
|
||||
},
|
||||
getContext: () => scriptContext.value,
|
||||
reinitialize: () => {
|
||||
isScriptInitialized.value = false;
|
||||
fieldChangeHandlers.value.clear();
|
||||
initializeScript();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component doesn't render visible content */
|
||||
</style>
|
@ -42,6 +42,11 @@
|
||||
Preview
|
||||
</RsButton>
|
||||
|
||||
<RsButton @click="showFormSettings = true" variant="secondary" size="sm">
|
||||
<Icon name="material-symbols:code" class="mr-1" />
|
||||
Form Settings
|
||||
</RsButton>
|
||||
|
||||
<RsButton @click="navigateToManage" variant="secondary" size="sm">
|
||||
<Icon name="material-symbols:settings" class="mr-1" />
|
||||
Manage Forms
|
||||
@ -197,21 +202,225 @@
|
||||
<!-- Preview Modal -->
|
||||
<RsModal v-model="showPreview" title="Form Preview" size="xl">
|
||||
<div class="max-h-[70vh] overflow-y-auto p-4">
|
||||
<FormKit type="form" @submit="handlePreviewSubmit" :actions="false">
|
||||
<div class="grid-preview-container">
|
||||
<template
|
||||
v-for="(component, index) in formStore.formComponents"
|
||||
:key="index"
|
||||
>
|
||||
<component-preview :component="component" :is-preview="false" />
|
||||
</template>
|
||||
</div>
|
||||
<FormKit type="submit" label="Submit" />
|
||||
</FormKit>
|
||||
<div class="form-container">
|
||||
<!-- Form Script Engine -->
|
||||
<FormScriptEngine
|
||||
:form-data="previewFormData"
|
||||
:custom-script="formStore.formCustomScript"
|
||||
:custom-css="formStore.formCustomCSS"
|
||||
:form-events="formStore.formEvents"
|
||||
:script-mode="formStore.scriptMode"
|
||||
@field-change="handleScriptFieldChange"
|
||||
@field-validate="handleScriptFieldValidate"
|
||||
/>
|
||||
|
||||
<FormKit type="form" @submit="handlePreviewSubmit" :actions="false" v-model="previewFormData">
|
||||
<div class="grid-preview-container">
|
||||
<template
|
||||
v-for="(component, index) in formStore.formComponents"
|
||||
:key="index"
|
||||
>
|
||||
<component-preview :component="component" :is-preview="false" />
|
||||
</template>
|
||||
</div>
|
||||
<FormKit type="submit" label="Submit" class="form-submit" />
|
||||
</FormKit>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer> </template>
|
||||
</RsModal>
|
||||
|
||||
<!-- Form Settings Modal -->
|
||||
<RsModal v-model="showFormSettings" title="Form Settings & Scripts" size="xl">
|
||||
<div class="max-h-[70vh] overflow-y-auto">
|
||||
<RsTab :tabs="settingsTabs" v-model="activeSettingsTab">
|
||||
<!-- Form Info Tab -->
|
||||
<template #info>
|
||||
<div class="p-4 space-y-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Form Name"
|
||||
v-model="formStore.formName"
|
||||
help="Name of your form"
|
||||
validation="required"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="textarea"
|
||||
label="Form Description"
|
||||
v-model="formStore.formDescription"
|
||||
help="Brief description of what this form is for"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom JavaScript Tab -->
|
||||
<template #javascript>
|
||||
<div class="p-4">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium mb-2">Custom JavaScript</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Write custom JavaScript to add dynamic behavior to your form.
|
||||
Use <code class="bg-gray-100 px-1 rounded">this.getField('fieldName')</code> to access form fields.
|
||||
</p>
|
||||
|
||||
<!-- Helper Functions Reference -->
|
||||
<div class="mb-4 p-3 bg-blue-50 rounded border border-blue-200">
|
||||
<details>
|
||||
<summary class="text-sm font-medium text-blue-800 cursor-pointer">📚 Available Helper Functions</summary>
|
||||
<div class="mt-2 text-xs text-blue-700 space-y-1">
|
||||
<div><code>this.getField('name')</code> - Get field value</div>
|
||||
<div><code>this.setField('name', value)</code> - Set field value</div>
|
||||
<div><code>this.hideField('name')</code> - Hide field</div>
|
||||
<div><code>this.showField('name')</code> - Show field</div>
|
||||
<div><code>this.disableField('name')</code> - Disable field</div>
|
||||
<div><code>this.enableField('name')</code> - Enable field</div>
|
||||
<div><code>this.validateField('name')</code> - Trigger field validation</div>
|
||||
<div><code>this.getAllFieldValues()</code> - Get all form values</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RsCodeMirror
|
||||
v-model="formStore.formCustomScript"
|
||||
language="javascript"
|
||||
:height="400"
|
||||
placeholder="// Example: Hide/show fields based on selection
|
||||
// this.onFieldChange('customer_type', (value) => {
|
||||
// if (value === 'business') {
|
||||
// this.showField('company_name');
|
||||
// this.showField('tax_id');
|
||||
// } else {
|
||||
// this.hideField('company_name');
|
||||
// this.hideField('tax_id');
|
||||
// }
|
||||
// });
|
||||
|
||||
// Example: Calculate total
|
||||
// this.onFieldChange(['quantity', 'price'], () => {
|
||||
// const qty = this.getField('quantity') || 0;
|
||||
// const price = this.getField('price') || 0;
|
||||
// this.setField('total', qty * price);
|
||||
// });"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom CSS Tab -->
|
||||
<template #css>
|
||||
<div class="p-4">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium mb-2">Custom CSS</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Add custom styles to enhance your form appearance. Use <code class="bg-gray-100 px-1 rounded">.form-container</code> to target the form.
|
||||
</p>
|
||||
|
||||
<!-- CSS Helper Reference -->
|
||||
<div class="mb-4 p-3 bg-green-50 rounded border border-green-200">
|
||||
<details>
|
||||
<summary class="text-sm font-medium text-green-800 cursor-pointer">🎨 CSS Selectors</summary>
|
||||
<div class="mt-2 text-xs text-green-700 space-y-1">
|
||||
<div><code>.form-container</code> - Main form wrapper</div>
|
||||
<div><code>.form-field</code> - Individual form fields</div>
|
||||
<div><code>.form-field[data-name="fieldName"]</code> - Specific field</div>
|
||||
<div><code>.form-section</code> - Form sections</div>
|
||||
<div><code>.form-submit</code> - Submit button</div>
|
||||
<div><code>.info-display</code> - Info display components</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RsCodeMirror
|
||||
v-model="formStore.formCustomCSS"
|
||||
language="css"
|
||||
:height="400"
|
||||
placeholder="/* Example: Custom field styling */
|
||||
/* .form-field[data-name='customer_name'] {
|
||||
background: #f0f9ff;
|
||||
border: 2px solid #0ea5e9;
|
||||
}
|
||||
|
||||
/* Example: Hide field initially */
|
||||
/* .form-field[data-name='hidden_field'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Example: Custom info display styling */
|
||||
/* .info-display {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
} */"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Script Events Tab -->
|
||||
<template #events>
|
||||
<div class="p-4">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium mb-2">Form Events</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Configure when your custom scripts should run.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="border rounded p-4">
|
||||
<h4 class="font-medium mb-2">Event Triggers</h4>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="formStore.formEvents.onLoad" class="mr-2">
|
||||
<span class="text-sm">On Form Load</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="formStore.formEvents.onFieldChange" class="mr-2">
|
||||
<span class="text-sm">On Field Change</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="formStore.formEvents.onSubmit" class="mr-2">
|
||||
<span class="text-sm">Before Form Submit</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="formStore.formEvents.onValidation" class="mr-2">
|
||||
<span class="text-sm">On Field Validation</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded p-4">
|
||||
<h4 class="font-medium mb-2">Script Execution Mode</h4>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="radio" v-model="formStore.scriptMode" value="safe" class="mr-2">
|
||||
<span class="text-sm">Safe Mode (Recommended) - Limited but secure</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" v-model="formStore.scriptMode" value="advanced" class="mr-2">
|
||||
<span class="text-sm">Advanced Mode - Full JavaScript access</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RsTab>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<RsButton @click="showFormSettings = false" variant="tertiary">
|
||||
Cancel
|
||||
</RsButton>
|
||||
<RsButton @click="saveFormSettings" variant="primary">
|
||||
Save Settings
|
||||
</RsButton>
|
||||
</div>
|
||||
</template>
|
||||
</RsModal>
|
||||
|
||||
<!-- Unsaved Changes Modal -->
|
||||
<RsModal v-model="showUnsavedChangesModal" title="Unsaved Changes" size="md" position="center">
|
||||
<div class="p-4">
|
||||
@ -272,6 +481,16 @@ const pendingNavigation = ref(null);
|
||||
const navigationTarget = ref(null);
|
||||
const navigationConfirmed = ref(false);
|
||||
const leftSidebarTab = ref('components');
|
||||
const showFormSettings = ref(false);
|
||||
const activeSettingsTab = ref('info');
|
||||
|
||||
// Settings tabs configuration
|
||||
const settingsTabs = [
|
||||
{ key: 'info', label: 'Form Info', icon: 'material-symbols:info-outline' },
|
||||
{ key: 'javascript', label: 'JavaScript', icon: 'material-symbols:code' },
|
||||
{ key: 'css', label: 'CSS', icon: 'material-symbols:palette-outline' },
|
||||
{ key: 'events', label: 'Events', icon: 'material-symbols:event-outline' }
|
||||
];
|
||||
|
||||
// Computed property for form name with getter and setter
|
||||
const formName = computed({
|
||||
@ -462,6 +681,47 @@ const handlePreviewSubmit = (formData) => {
|
||||
toast.success("Form submitted successfully");
|
||||
};
|
||||
|
||||
const saveFormSettings = () => {
|
||||
// Form settings are automatically saved through the store's reactive updates
|
||||
showFormSettings.value = false;
|
||||
toast.success("Form settings saved successfully");
|
||||
};
|
||||
|
||||
// Preview form data for script interactions
|
||||
const previewFormData = ref({});
|
||||
|
||||
// Initialize preview form data with default values
|
||||
watchEffect(() => {
|
||||
const newFormData = {};
|
||||
formStore.formComponents.forEach(component => {
|
||||
if (component.props.name) {
|
||||
// Set default values based on component type
|
||||
switch (component.type) {
|
||||
case 'checkbox':
|
||||
newFormData[component.props.name] = [];
|
||||
break;
|
||||
case 'number':
|
||||
newFormData[component.props.name] = 0;
|
||||
break;
|
||||
default:
|
||||
newFormData[component.props.name] = component.props.value || '';
|
||||
}
|
||||
}
|
||||
});
|
||||
previewFormData.value = newFormData;
|
||||
});
|
||||
|
||||
// Handle script-driven field changes
|
||||
const handleScriptFieldChange = ({ fieldName, value }) => {
|
||||
previewFormData.value[fieldName] = value;
|
||||
};
|
||||
|
||||
// Handle script-driven field validation
|
||||
const handleScriptFieldValidate = ({ fieldName }) => {
|
||||
// Could integrate with FormKit validation here
|
||||
console.log(`Validating field: ${fieldName}`);
|
||||
};
|
||||
|
||||
const navigateToManage = () => {
|
||||
// If already confirmed or no unsaved changes, navigate directly
|
||||
if (navigationConfirmed.value || !formStore.hasUnsavedChanges) {
|
||||
|
@ -86,6 +86,10 @@ model form {
|
||||
formName String @db.VarChar(255)
|
||||
formDescription String? @db.Text
|
||||
formComponents Json
|
||||
customScript String? @db.LongText
|
||||
customCSS String? @db.Text
|
||||
formEvents Json?
|
||||
scriptMode String? @default("safe") @db.VarChar(20)
|
||||
formStatus String @default("active") @db.VarChar(50)
|
||||
formCreatedBy Int?
|
||||
formCreatedDate DateTime @default(now()) @db.DateTime(0)
|
||||
|
@ -25,7 +25,11 @@ export default defineEventHandler(async (event) => {
|
||||
formDescription: body.formDescription || null,
|
||||
formComponents: body.components || [],
|
||||
formStatus: body.status || 'active',
|
||||
formCreatedBy: body.createdBy || null // In a real app, this would come from the authenticated user
|
||||
formCreatedBy: body.createdBy || null, // In a real app, this would come from the authenticated user
|
||||
customScript: body.customScript || null,
|
||||
customCSS: body.customCSS || null,
|
||||
formEvents: body.formEvents || null,
|
||||
scriptMode: body.scriptMode || 'safe'
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -13,7 +13,18 @@ export const useFormBuilderStore = defineStore('formBuilder', {
|
||||
actionHistory: [],
|
||||
currentHistoryIndex: -1,
|
||||
maxHistoryLength: 30, // Maximum number of history entries to keep
|
||||
currentFormId: null
|
||||
currentFormId: null,
|
||||
|
||||
// Custom scripts and styling
|
||||
formCustomScript: '',
|
||||
formCustomCSS: '',
|
||||
formEvents: {
|
||||
onLoad: true,
|
||||
onFieldChange: true,
|
||||
onSubmit: false,
|
||||
onValidation: false
|
||||
},
|
||||
scriptMode: 'safe' // 'safe' or 'advanced'
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@ -467,7 +478,11 @@ export const useFormBuilderStore = defineStore('formBuilder', {
|
||||
components: this.formComponents.map(c => ({
|
||||
type: c.type,
|
||||
props: c.props
|
||||
}))
|
||||
})),
|
||||
customScript: this.formCustomScript,
|
||||
customCSS: this.formCustomCSS,
|
||||
formEvents: this.formEvents,
|
||||
scriptMode: this.scriptMode
|
||||
};
|
||||
|
||||
// Determine if this is a new form or an update
|
||||
@ -544,6 +559,17 @@ export const useFormBuilderStore = defineStore('formBuilder', {
|
||||
this.formDescription = result.form.formDescription || '';
|
||||
this.currentFormId = result.form.formUUID;
|
||||
|
||||
// Load custom scripts and settings
|
||||
this.formCustomScript = result.form.customScript || '';
|
||||
this.formCustomCSS = result.form.customCSS || '';
|
||||
this.formEvents = result.form.formEvents || {
|
||||
onLoad: true,
|
||||
onFieldChange: true,
|
||||
onSubmit: false,
|
||||
onValidation: false
|
||||
};
|
||||
this.scriptMode = result.form.scriptMode || 'safe';
|
||||
|
||||
// Transform components from DB format to store format
|
||||
if (Array.isArray(result.form.formComponents)) {
|
||||
this.formComponents = result.form.formComponents.map(c => ({
|
||||
@ -607,6 +633,17 @@ export const useFormBuilderStore = defineStore('formBuilder', {
|
||||
this.currentFormId = null;
|
||||
this.hasUnsavedChanges = false;
|
||||
|
||||
// Reset custom scripts and settings
|
||||
this.formCustomScript = '';
|
||||
this.formCustomCSS = '';
|
||||
this.formEvents = {
|
||||
onLoad: true,
|
||||
onFieldChange: true,
|
||||
onSubmit: false,
|
||||
onValidation: false
|
||||
};
|
||||
this.scriptMode = 'safe';
|
||||
|
||||
// Clear history when starting a new form and add initial state
|
||||
this.actionHistory = [];
|
||||
this.currentHistoryIndex = -1;
|
||||
|
Loading…
x
Reference in New Issue
Block a user