Enhance ComponentPreview and FormBuilderFieldSettingsModal for Improved Field Management
- Updated the ComponentPreview component to utilize a new utility function, safeGetField, for safer field value retrieval from form data, enhancing data integrity and error handling. - Added a new "Read Only" toggle option in the FormBuilderFieldSettingsModal, allowing users to set fields as non-editable, improving form configuration flexibility. - Refactored various field access patterns to ensure consistent handling of undefined or null values across components. - Improved the overall user experience by ensuring that field states are accurately reflected and managed during form previews.
This commit is contained in:
parent
8b44adf06e
commit
84e32e4dc7
@ -26,7 +26,8 @@
|
||||
<FormKit v-else-if="isInputType" :id="`preview-${component.id}`" :type="component.type" :name="component.props.name"
|
||||
:label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder"
|
||||
:validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'"
|
||||
:readonly="isPreview" :options="component.props.options || undefined" :value="component.props.value || undefined"
|
||||
:readonly="component.props.readonly || isPreview"
|
||||
:disabled="isPreview" :options="component.props.options || undefined" :value="component.props.value || undefined"
|
||||
:accept="component.props.accept || undefined" :max="component.props.max || undefined"
|
||||
:mask="component.props.mask || undefined" :digits="component.props.digits || undefined"
|
||||
:multiple="component.props.multiple || undefined" :maxSize="component.props.maxSize || undefined"
|
||||
@ -162,11 +163,11 @@
|
||||
|
||||
<!-- Functional groups (in form view) -->
|
||||
<div v-else class="repeating-groups space-y-4">
|
||||
<div v-for="(group, groupIndex) in (previewFormData[component.props.name] || [])" :key="groupIndex"
|
||||
<div v-for="(group, groupIndex) in (safeGetField(component.props.name, previewFormData) || [])" :key="groupIndex"
|
||||
class="group-item border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h4 class="text-sm font-medium text-gray-700">Item {{ groupIndex + 1 }}</h4>
|
||||
<button v-if="(previewFormData[component.props.name]?.length || 0) > (component.props.minItems || 1)"
|
||||
<button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) > (component.props.minItems || 1)"
|
||||
type="button" class="text-red-500 hover:text-red-700 text-sm" @click="removeGroupItem(groupIndex)">
|
||||
{{ component.props.removeText || 'Remove' }}
|
||||
</button>
|
||||
@ -181,7 +182,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="(previewFormData[component.props.name]?.length || 0) < (component.props.maxItems || 10)"
|
||||
<button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) < (component.props.maxItems || 10)"
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-blue-600 text-blue-600 bg-white hover:bg-blue-50 rounded text-sm"
|
||||
@click="addGroupItem">
|
||||
@ -198,7 +199,7 @@
|
||||
<label v-if="component.props.label" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{{ component.props.label }}
|
||||
<span v-if="component.props.showItemCounter" class="text-xs text-gray-500 ml-2">
|
||||
({{ (previewFormData[component.props.name] || []).length }}/{{ component.props.maxItems || 20 }})
|
||||
({{ (safeGetField(component.props.name, previewFormData) || []).length }}/{{ component.props.maxItems || 20 }})
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@ -217,7 +218,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Bulk Operations Toolbar (if enabled) -->
|
||||
<div v-if="component.props.bulkOperations && (previewFormData[component.props.name] || []).length > 0"
|
||||
<div v-if="component.props.bulkOperations && (safeGetField(component.props.name, previewFormData) || []).length > 0"
|
||||
class="mb-3 flex items-center justify-between bg-gray-50 p-2 rounded">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" :checked="isAllSelected(component.props.name)"
|
||||
@ -270,7 +271,7 @@
|
||||
:checked="isItemSelected(component.props.name, index)"
|
||||
@change="toggleItemSelection(component.props.name, index)"
|
||||
class="mr-2 h-4 w-4 rounded border-gray-300" />
|
||||
<input :type="component.props.itemType || 'text'" v-model="previewFormData[component.props.name][index]"
|
||||
<input :type="component.props.itemType || 'text'" v-model="safeGetField(component.props.name, previewFormData)[index]"
|
||||
:placeholder="component.props.placeholder"
|
||||
:class="getItemInputClasses(component.props.name, index, item)"
|
||||
@blur="validateItem(component.props.name, index, item)"
|
||||
@ -279,7 +280,7 @@
|
||||
class="ml-2 cursor-move text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Icon name="material-symbols:drag-indicator" class="w-5 h-5" />
|
||||
</div>
|
||||
<button v-if="(previewFormData[component.props.name]?.length || 0) > (component.props.minItems || 0)"
|
||||
<button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) > (component.props.minItems || 0)"
|
||||
type="button" class="ml-2 text-red-500 hover:text-red-700" @click="removeListItem(index)">
|
||||
<Icon name="material-symbols:delete-outline" class="w-5 h-5" />
|
||||
</button>
|
||||
@ -290,7 +291,7 @@
|
||||
{{ validationErrors[component.props.name] }}
|
||||
</div>
|
||||
|
||||
<button v-if="(previewFormData[component.props.name]?.length || 0) < (component.props.maxItems || 20)"
|
||||
<button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) < (component.props.maxItems || 20)"
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-blue-600 text-blue-600 bg-white hover:bg-blue-50 rounded text-sm"
|
||||
@click="addListItem">
|
||||
@ -299,7 +300,7 @@
|
||||
</button>
|
||||
|
||||
<!-- Export Button (if enabled) -->
|
||||
<div v-if="component.props.exportFormat && (previewFormData[component.props.name] || []).length > 0"
|
||||
<div v-if="component.props.exportFormat && (safeGetField(component.props.name, previewFormData) || []).length > 0"
|
||||
class="mt-2">
|
||||
<button @click="exportItems(component.props.name, component.props.exportFormat)"
|
||||
class="text-xs text-green-600 hover:text-green-800 flex items-center">
|
||||
@ -316,7 +317,7 @@
|
||||
<div v-else-if="component.type === 'repeating-table'" class="repeating-table-wrapper">
|
||||
<RepeatingTable
|
||||
:config="component.props"
|
||||
:model-value="previewFormData[component.props.name] || []"
|
||||
:model-value="safeGetField(component.props.name, previewFormData) || []"
|
||||
:is-preview="isPreview"
|
||||
@update:model-value="updateTableData"
|
||||
/>
|
||||
@ -515,6 +516,8 @@
|
||||
import { useNuxtApp } from '#app';
|
||||
import { useFormBuilderStore } from '~/stores/formBuilder';
|
||||
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
|
||||
import { safeGetField } from '~/composables/safeGetField';
|
||||
import { onMounted, onUnmounted, watch, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
@ -549,6 +552,27 @@ const sectionDropStates = ref({});
|
||||
const showNestedSettingsModal = ref(false);
|
||||
const selectedNestedComponent = ref(null);
|
||||
|
||||
// Track timers and DOM elements for cleanup
|
||||
let lightbox = null;
|
||||
let importInput = null;
|
||||
let timers = [];
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up any lightbox DOM element
|
||||
if (lightbox && document.body.contains(lightbox)) {
|
||||
document.body.removeChild(lightbox);
|
||||
lightbox = null;
|
||||
}
|
||||
// Clean up import input
|
||||
if (importInput && document.body.contains(importInput)) {
|
||||
document.body.removeChild(importInput);
|
||||
importInput = null;
|
||||
}
|
||||
// Clear all timers
|
||||
timers.forEach(t => clearTimeout(t));
|
||||
timers = [];
|
||||
});
|
||||
|
||||
// Initialize data for components
|
||||
onMounted(() => {
|
||||
// Initialize search and selection data for dynamic lists
|
||||
@ -560,7 +584,7 @@ onMounted(() => {
|
||||
validationErrors.value[listName] = '';
|
||||
|
||||
// Initialize form data with default items if they exist and form data is empty
|
||||
const currentFormData = formStore.previewFormData[listName];
|
||||
const currentFormData = safeGetField(listName, formStore.previewFormData);
|
||||
const defaultItems = props.component.props.defaultItems;
|
||||
|
||||
if ((!currentFormData || currentFormData.length === 0) && defaultItems && defaultItems.length > 0) {
|
||||
@ -593,7 +617,7 @@ const addGroupItem = () => {
|
||||
if (!groupName) return;
|
||||
|
||||
// Get current groups or initialize empty array
|
||||
const currentGroups = formStore.previewFormData[groupName] || [];
|
||||
const currentGroups = safeGetField(groupName, formStore.previewFormData) || [];
|
||||
|
||||
// Create a new empty group
|
||||
const newGroup = {};
|
||||
@ -620,7 +644,7 @@ const removeGroupItem = (index) => {
|
||||
if (!groupName) return;
|
||||
|
||||
// Get current groups
|
||||
const currentGroups = [...(formStore.previewFormData[groupName] || [])];
|
||||
const currentGroups = [...(safeGetField(groupName, formStore.previewFormData) || [])];
|
||||
|
||||
// Remove the group at the specified index
|
||||
currentGroups.splice(index, 1);
|
||||
@ -637,7 +661,7 @@ const addListItem = () => {
|
||||
if (!listName) return;
|
||||
|
||||
// Get current items or initialize empty array
|
||||
const currentItems = [...(formStore.previewFormData[listName] || [])];
|
||||
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
|
||||
|
||||
// Add an empty item
|
||||
currentItems.push('');
|
||||
@ -654,7 +678,7 @@ const removeListItem = (index) => {
|
||||
if (!listName) return;
|
||||
|
||||
// Get current items
|
||||
const currentItems = [...(formStore.previewFormData[listName] || [])];
|
||||
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
|
||||
|
||||
// Remove the item at the specified index
|
||||
currentItems.splice(index, 1);
|
||||
@ -723,7 +747,7 @@ const validateItem = (listName, index, value) => {
|
||||
const checkDuplicates = (listName, newValue, currentIndex) => {
|
||||
if (props.component.props.allowDuplicates) return true;
|
||||
|
||||
const currentItems = formStore.previewFormData[listName] || [];
|
||||
const currentItems = safeGetField(listName, formStore.previewFormData) || [];
|
||||
const duplicateIndex = currentItems.findIndex((item, index) =>
|
||||
index !== currentIndex && item.toLowerCase() === newValue.toLowerCase()
|
||||
);
|
||||
@ -762,7 +786,7 @@ const getItemInputClasses = (listName, index, value) => {
|
||||
|
||||
// Filter items based on search query
|
||||
const getFilteredItems = (listName) => {
|
||||
const items = formStore.previewFormData[listName] || [];
|
||||
const items = safeGetField(listName, formStore.previewFormData) || [];
|
||||
const query = searchQuery.value[listName];
|
||||
|
||||
if (!query || !props.component.props.enableSearch) {
|
||||
@ -795,13 +819,13 @@ const toggleItemSelection = (listName, index) => {
|
||||
};
|
||||
|
||||
const isAllSelected = (listName) => {
|
||||
const items = formStore.previewFormData[listName] || [];
|
||||
const items = safeGetField(listName, formStore.previewFormData) || [];
|
||||
const selected = selectedItems.value[listName] || [];
|
||||
return items.length > 0 && selected.length === items.length;
|
||||
};
|
||||
|
||||
const toggleSelectAll = (listName) => {
|
||||
const items = formStore.previewFormData[listName] || [];
|
||||
const items = safeGetField(listName, formStore.previewFormData) || [];
|
||||
|
||||
if (isAllSelected(listName)) {
|
||||
selectedItems.value[listName] = [];
|
||||
@ -812,7 +836,7 @@ const toggleSelectAll = (listName) => {
|
||||
|
||||
const deleteSelectedItems = (listName) => {
|
||||
if (!props.component.props.confirmDelete || confirm('Are you sure you want to delete the selected items?')) {
|
||||
const currentItems = [...(formStore.previewFormData[listName] || [])];
|
||||
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
|
||||
const selected = selectedItems.value[listName] || [];
|
||||
|
||||
// Sort indices in descending order to avoid index shifting issues
|
||||
@ -834,7 +858,7 @@ const deleteSelectedItems = (listName) => {
|
||||
|
||||
// Import/Export functionality
|
||||
const exportItems = (listName, format) => {
|
||||
const items = formStore.previewFormData[listName] || [];
|
||||
const items = safeGetField(listName, formStore.previewFormData) || [];
|
||||
let content = '';
|
||||
let filename = `${listName}_items`;
|
||||
let mimeType = 'text/plain';
|
||||
@ -867,20 +891,17 @@ const exportItems = (listName, format) => {
|
||||
};
|
||||
|
||||
const showImportModal = (listName) => {
|
||||
// This would open a modal for importing items
|
||||
// For now, let's use a simple file input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.txt,.csv,.json';
|
||||
input.onchange = (event) => {
|
||||
importInput = document.createElement('input');
|
||||
importInput.type = 'file';
|
||||
importInput.accept = '.txt,.csv,.json';
|
||||
importInput.onchange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target.result;
|
||||
let importedItems = [];
|
||||
|
||||
try {
|
||||
const content = e.target.result;
|
||||
if (file.name.endsWith('.json')) {
|
||||
importedItems = JSON.parse(content);
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
@ -888,22 +909,15 @@ const showImportModal = (listName) => {
|
||||
} else {
|
||||
importedItems = content.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
// Add imported items to current list
|
||||
const currentItems = [...(formStore.previewFormData[listName] || [])];
|
||||
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
|
||||
const newItems = [...currentItems, ...importedItems];
|
||||
|
||||
// Respect max items limit
|
||||
const maxItems = props.component.props.maxItems || 20;
|
||||
if (newItems.length > maxItems) {
|
||||
newItems.splice(maxItems);
|
||||
alert(`Import limited to ${maxItems} items total.`);
|
||||
}
|
||||
|
||||
// Update form data
|
||||
const updatedData = { ...formStore.previewFormData, [listName]: newItems };
|
||||
formStore.updatePreviewFormData(updatedData);
|
||||
|
||||
} catch (error) {
|
||||
alert('Error importing file. Please check the file format.');
|
||||
}
|
||||
@ -911,7 +925,7 @@ const showImportModal = (listName) => {
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
importInput.click();
|
||||
};
|
||||
|
||||
// Handle button click
|
||||
@ -971,8 +985,11 @@ const handleImageClick = (event) => {
|
||||
const imageUrl = props.component.props.imageUrl;
|
||||
if (!imageUrl) return;
|
||||
|
||||
// Create a lightbox effect to view the image
|
||||
const lightbox = document.createElement('div');
|
||||
// Remove previous lightbox if exists
|
||||
if (lightbox && document.body.contains(lightbox)) {
|
||||
document.body.removeChild(lightbox);
|
||||
}
|
||||
lightbox = document.createElement('div');
|
||||
lightbox.style.position = 'fixed';
|
||||
lightbox.style.top = '0';
|
||||
lightbox.style.left = '0';
|
||||
@ -994,7 +1011,10 @@ const handleImageClick = (event) => {
|
||||
|
||||
// Add close functionality
|
||||
lightbox.onclick = () => {
|
||||
document.body.removeChild(lightbox);
|
||||
if (document.body.contains(lightbox)) {
|
||||
document.body.removeChild(lightbox);
|
||||
}
|
||||
lightbox = null;
|
||||
};
|
||||
|
||||
// Prevent image click from closing the lightbox
|
||||
|
@ -217,6 +217,16 @@
|
||||
placeholder="e.g., We'll use this to send you updates about your order"
|
||||
rows="2"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
v-if="showField('readonly')"
|
||||
type="switch"
|
||||
label="Read Only"
|
||||
name="readonly"
|
||||
v-model="configModel.readonly"
|
||||
help="Users cannot edit this field value"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@ -1735,7 +1745,8 @@ const showField = (fieldName) => {
|
||||
value: ['heading', 'paragraph', 'hidden'],
|
||||
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
||||
options: ['select', 'checkbox', 'radio'],
|
||||
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group']
|
||||
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
||||
readonly: ['text', 'number', 'email', 'textarea', 'mask', 'url', 'tel']
|
||||
}
|
||||
|
||||
return fieldConfig[fieldName]?.includes(props.component.type) || false
|
||||
@ -1779,7 +1790,7 @@ const isTextBasedField = computed(() => {
|
||||
// Type changing functionality
|
||||
const compatibilityGroups = {
|
||||
// Text-based inputs (can switch between each other)
|
||||
textInputs: ['text', 'textarea', 'email', 'password', 'url', 'tel', 'mask'],
|
||||
textInputs: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask'],
|
||||
|
||||
// Selection inputs (can switch between each other)
|
||||
selectionInputs: ['select', 'radio', 'checkbox'],
|
||||
|
@ -292,10 +292,13 @@ const handleFieldChange = (fieldName, newValue, oldValue) => {
|
||||
};
|
||||
|
||||
// Watch for script changes and reinitialize
|
||||
watch(() => props.customScript, (newScript, oldScript) => {
|
||||
watch(() => props.customScript, async (newScript, oldScript) => {
|
||||
if (newScript !== oldScript) {
|
||||
isScriptInitialized.value = false;
|
||||
initializeScript();
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
initializeScript();
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
@ -325,10 +328,11 @@ watch(() => props.customCSS, () => {
|
||||
});
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
initializeScript();
|
||||
}, 500); // Delay to ensure parent DOM and initial props are settled
|
||||
}, 200); // Delay to ensure all FormKit fields are loaded
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
11
composables/safeGetField.js
Normal file
11
composables/safeGetField.js
Normal file
@ -0,0 +1,11 @@
|
||||
// Utility to safely get a field value from a form data object
|
||||
export function safeGetField(field, formData) {
|
||||
if (formData && Object.prototype.hasOwnProperty.call(formData, field)) {
|
||||
return formData[field] !== undefined && formData[field] !== null ? formData[field] : '';
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Only warn in development
|
||||
console.warn(`Field '${field}' is missing or inaccessible.`);
|
||||
}
|
||||
return '';
|
||||
}
|
@ -1,232 +1,273 @@
|
||||
// Manager Approval Form Custom Script Engine
|
||||
// This script provides dynamic behavior for the manager approval form
|
||||
|
||||
console.log('Manager Approval Form Script Loaded');
|
||||
console.log("Manager Approval Form Script Loaded");
|
||||
|
||||
// Auto-set approval date to today
|
||||
const setApprovalDate = () => {
|
||||
const today = new Date();
|
||||
const formattedDate = today.toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
setField('approval_date', formattedDate);
|
||||
const formattedDate = today.toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
setField("approval_date", formattedDate);
|
||||
};
|
||||
|
||||
// Calculate and display recommended approval amounts
|
||||
const calculateRecommendedAmounts = () => {
|
||||
const totalClaimed = parseFloat(getField('total_cost_display')) || 0;
|
||||
const policyLimit = parseFloat(getField('policy_limit_display')) || 0;
|
||||
const overBudget = parseFloat(getField('over_budget_amount_display')) || 0;
|
||||
|
||||
const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
|
||||
const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
|
||||
const overBudget = parseFloat(getField("over_budget_amount_display")) || 0;
|
||||
|
||||
if (totalClaimed > 0 && policyLimit > 0) {
|
||||
const percentageOver = ((overBudget / policyLimit) * 100).toFixed(1);
|
||||
showInfo(`This claim is ${percentageOver}% over the policy limit. Policy limit: RM${policyLimit.toFixed(2)}, Claimed: RM${totalClaimed.toFixed(2)}`);
|
||||
showInfo(
|
||||
`This claim is ${percentageOver}% over the policy limit. Policy limit: RM${policyLimit.toFixed(2)}, Claimed: RM${totalClaimed.toFixed(2)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate manager decision and provide guidance
|
||||
const handleDecisionChange = (decision) => {
|
||||
const totalClaimed = parseFloat(getField('total_cost_display')) || 0;
|
||||
const policyLimit = parseFloat(getField('policy_limit_display')) || 0;
|
||||
|
||||
console.log('Manager decision changed to:', decision);
|
||||
|
||||
switch(decision) {
|
||||
case 'approve_full':
|
||||
showField('custom_approved_amount');
|
||||
showInfo(`💰 Approving full amount: RM${totalClaimed.toFixed(2)}. You can enter a custom amount if needed.`);
|
||||
setField('custom_approved_amount', totalClaimed.toString());
|
||||
const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
|
||||
const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
|
||||
|
||||
console.log("Manager decision changed to:", decision);
|
||||
|
||||
switch (decision) {
|
||||
case "approve_full":
|
||||
showField("custom_approved_amount");
|
||||
showInfo(
|
||||
`💰 Approving full amount: RM${totalClaimed.toFixed(2)}. You can enter a custom amount if needed.`
|
||||
);
|
||||
setField("custom_approved_amount", totalClaimed.toString());
|
||||
break;
|
||||
|
||||
case 'approve_policy':
|
||||
hideField('custom_approved_amount');
|
||||
showInfo(`⚖️ Approving policy limit only: RM${policyLimit.toFixed(2)}. Employee will be notified of the reduced amount.`);
|
||||
|
||||
case "approve_policy":
|
||||
hideField("custom_approved_amount");
|
||||
showInfo(
|
||||
`⚖️ Approving policy limit only: RM${policyLimit.toFixed(2)}. Employee will be notified of the reduced amount.`
|
||||
);
|
||||
break;
|
||||
|
||||
case 'reject':
|
||||
hideField('custom_approved_amount');
|
||||
showError('❌ Claim will be rejected. Please provide detailed comments explaining the rejection reason.');
|
||||
|
||||
case "reject":
|
||||
hideField("custom_approved_amount");
|
||||
showError(
|
||||
"❌ Claim will be rejected. Please provide detailed comments explaining the rejection reason."
|
||||
);
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
hideField('custom_approved_amount');
|
||||
hideField("custom_approved_amount");
|
||||
}
|
||||
};
|
||||
|
||||
// Validate custom approved amount
|
||||
const validateCustomAmount = (amount) => {
|
||||
const numAmount = parseFloat(amount) || 0;
|
||||
const totalClaimed = parseFloat(getField('total_cost_display')) || 0;
|
||||
const policyLimit = parseFloat(getField('policy_limit_display')) || 0;
|
||||
|
||||
const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
|
||||
const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
|
||||
|
||||
if (numAmount < 0) {
|
||||
showError('Approved amount cannot be negative');
|
||||
showError("Approved amount cannot be negative");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (numAmount > totalClaimed) {
|
||||
showError(`Approved amount (RM${numAmount}) cannot exceed claimed amount (RM${totalClaimed})`);
|
||||
showError(
|
||||
`Approved amount (RM${numAmount}) cannot exceed claimed amount (RM${totalClaimed})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (numAmount > 0 && numAmount < policyLimit) {
|
||||
showInfo(`Custom amount (RM${numAmount}) is less than policy limit (RM${policyLimit}). Consider approving policy limit instead.`);
|
||||
showInfo(
|
||||
`Custom amount (RM${numAmount}) is less than policy limit (RM${policyLimit}). Consider approving policy limit instead.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Validate manager comments based on decision
|
||||
const validateComments = (comments, decision) => {
|
||||
if (!comments || comments.trim().length < 10) {
|
||||
showError('Please provide detailed comments (minimum 10 characters)');
|
||||
showError("Please provide detailed comments (minimum 10 characters)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (decision === 'reject' && comments.length < 50) {
|
||||
showError('Rejection requires detailed explanation (minimum 50 characters)');
|
||||
|
||||
if (decision === "reject" && comments.length < 50) {
|
||||
showError(
|
||||
"Rejection requires detailed explanation (minimum 50 characters)"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (decision === 'approve_full') {
|
||||
const totalClaimed = parseFloat(getField('total_cost_display')) || 0;
|
||||
const policyLimit = parseFloat(getField('policy_limit_display')) || 0;
|
||||
|
||||
if (decision === "approve_full") {
|
||||
const totalClaimed = parseFloat(getField("total_cost_display")) || 0;
|
||||
const policyLimit = parseFloat(getField("policy_limit_display")) || 0;
|
||||
const overBudget = totalClaimed - policyLimit;
|
||||
|
||||
if (overBudget > 500 && !comments.toLowerCase().includes('business')) {
|
||||
showInfo('Consider mentioning business justification for approving over-budget amounts > RM500');
|
||||
|
||||
if (overBudget > 500 && !comments.toLowerCase().includes("business")) {
|
||||
showInfo(
|
||||
"Consider mentioning business justification for approving over-budget amounts > RM500"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Format manager name (title case)
|
||||
const formatManagerName = (name) => {
|
||||
if (!name || typeof name !== 'string') return name;
|
||||
|
||||
if (!name || typeof name !== "string") return name;
|
||||
|
||||
return name
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// Form validation before submission
|
||||
const validateApprovalForm = () => {
|
||||
let isValid = true;
|
||||
const errors = [];
|
||||
|
||||
|
||||
// Check required fields
|
||||
const requiredFields = {
|
||||
'manager_decision': 'Approval Decision',
|
||||
'manager_comments': 'Manager Comments',
|
||||
'manager_name': 'Manager Name',
|
||||
'approval_date': 'Approval Date'
|
||||
manager_decision: "Approval Decision",
|
||||
manager_comments: "Manager Comments",
|
||||
manager_name: "Manager Name",
|
||||
approval_date: "Approval Date",
|
||||
};
|
||||
|
||||
|
||||
Object.entries(requiredFields).forEach(([fieldName, displayName]) => {
|
||||
const value = getField(fieldName);
|
||||
if (!value || value.toString().trim() === '') {
|
||||
if (!value || value.toString().trim() === "") {
|
||||
errors.push(`${displayName} is required`);
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Validate decision-specific requirements
|
||||
const decision = getField('manager_decision');
|
||||
const comments = getField('manager_comments');
|
||||
const customAmount = getField('custom_approved_amount');
|
||||
|
||||
const decision = getField("manager_decision");
|
||||
const comments = getField("manager_comments");
|
||||
const customAmount = getField("custom_approved_amount");
|
||||
|
||||
if (decision && comments) {
|
||||
if (!validateComments(comments, decision)) {
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (decision === 'approve_full' && customAmount) {
|
||||
|
||||
if (decision === "approve_full" && customAmount) {
|
||||
if (!validateCustomAmount(customAmount)) {
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Validate approval date is not in the future
|
||||
const approvalDate = getField('approval_date');
|
||||
const approvalDate = getField("approval_date");
|
||||
if (approvalDate) {
|
||||
const selectedDate = new Date(approvalDate);
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999); // End of today
|
||||
|
||||
|
||||
if (selectedDate > today) {
|
||||
errors.push('Approval date cannot be in the future');
|
||||
errors.push("Approval date cannot be in the future");
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Show validation results
|
||||
if (errors.length > 0) {
|
||||
showError(`Please fix the following errors:\n• ${errors.join('\n• ')}`);
|
||||
showError(`Please fix the following errors:\n• ${errors.join("\n• ")}`);
|
||||
} else {
|
||||
showSuccess('Form validation passed! Ready to submit approval decision.');
|
||||
showSuccess("Form validation passed! Ready to submit approval decision.");
|
||||
}
|
||||
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// Set up field change handlers
|
||||
onFieldChange('manager_decision', (newValue) => {
|
||||
onFieldChange("manager_decision", (newValue) => {
|
||||
if (newValue) {
|
||||
handleDecisionChange(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
onFieldChange('custom_approved_amount', (newValue) => {
|
||||
onFieldChange("custom_approved_amount", (newValue) => {
|
||||
if (newValue) {
|
||||
validateCustomAmount(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
onFieldChange('manager_comments', (newValue) => {
|
||||
const decision = getField('manager_decision');
|
||||
onFieldChange("manager_comments", (newValue) => {
|
||||
const decision = getField("manager_decision");
|
||||
if (newValue && decision) {
|
||||
validateComments(newValue, decision);
|
||||
}
|
||||
});
|
||||
|
||||
onFieldChange('manager_name', (newValue) => {
|
||||
if (newValue && typeof newValue === 'string') {
|
||||
onFieldChange("manager_name", (newValue) => {
|
||||
if (newValue && typeof newValue === "string") {
|
||||
const formatted = formatManagerName(newValue);
|
||||
if (formatted !== newValue) {
|
||||
setTimeout(() => {
|
||||
setField('manager_name', formatted);
|
||||
setField("manager_name", formatted);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize form on load
|
||||
setTimeout(() => {
|
||||
setApprovalDate();
|
||||
calculateRecommendedAmounts();
|
||||
|
||||
// Make display fields read-only by adding visual styling
|
||||
const displayFields = [
|
||||
'claim_summary', 'employee_name_display', 'department_display',
|
||||
'trip_purpose_display', 'destination_display', 'total_cost_display',
|
||||
'policy_limit_display', 'over_budget_amount_display'
|
||||
];
|
||||
|
||||
displayFields.forEach(fieldName => {
|
||||
const fieldElement = document.querySelector(`[data-name="${fieldName}"] input`);
|
||||
if (fieldElement) {
|
||||
fieldElement.style.backgroundColor = '#f3f4f6';
|
||||
fieldElement.style.cursor = 'not-allowed';
|
||||
fieldElement.readOnly = true;
|
||||
// Wait until all required fields are present before running initialization
|
||||
function waitForFields(fields, callback, maxAttempts = 20) {
|
||||
let attempts = 0;
|
||||
function check() {
|
||||
const allPresent = fields.every(f => getField(f) !== undefined);
|
||||
if (allPresent) {
|
||||
callback();
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(check, 100);
|
||||
} else {
|
||||
showError("Some required fields are missing. Please reload the page.");
|
||||
}
|
||||
});
|
||||
|
||||
showInfo('Review the claim details above and make your approval decision below.');
|
||||
}, 1000);
|
||||
}
|
||||
check();
|
||||
}
|
||||
|
||||
waitForFields(
|
||||
[
|
||||
"total_cost_display",
|
||||
"policy_limit_display",
|
||||
"over_budget_amount_display",
|
||||
// add any other required fields here
|
||||
],
|
||||
() => {
|
||||
setApprovalDate();
|
||||
calculateRecommendedAmounts();
|
||||
showInfo("Review the claim details above and make your approval decision below.");
|
||||
}
|
||||
);
|
||||
|
||||
// Expose validation function for form submission
|
||||
window.validateManagerApprovalForm = validateApprovalForm;
|
||||
|
||||
console.log('Manager Approval Form Script initialized successfully');
|
||||
console.log("Manager Approval Form Script initialized successfully");
|
||||
|
||||
// Conditional logic for field: custom_approved_amount
|
||||
onFieldChange("manager_decision", function () {
|
||||
if (getField("manager_decision") === "approve_full") {
|
||||
showField("custom_approved_amount");
|
||||
} else {
|
||||
hideField("custom_approved_amount");
|
||||
}
|
||||
});
|
||||
|
||||
// Initial evaluation for field: custom_approved_amount
|
||||
(function () {
|
||||
if (getField("manager_decision") === "approve_full") {
|
||||
showField("custom_approved_amount");
|
||||
} else {
|
||||
hideField("custom_approved_amount");
|
||||
}
|
||||
})();
|
||||
|
@ -158,60 +158,42 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"props": {
|
||||
"help": "Total amount claimed by employee",
|
||||
"name": "total_cost_display",
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"label": "Total Claimed Amount (RM)",
|
||||
"width": "33.33%",
|
||||
"gridColumn": "span 4",
|
||||
"validation": "",
|
||||
"placeholder": "0.00",
|
||||
"conditionalLogic": {
|
||||
"action": "show",
|
||||
"enabled": false,
|
||||
"operator": "and",
|
||||
"conditions": []
|
||||
}
|
||||
"placeholder": "0.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"props": {
|
||||
"help": "Maximum allowed per company policy",
|
||||
"name": "policy_limit_display",
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"label": "Policy Limit (RM)",
|
||||
"width": "33.33%",
|
||||
"gridColumn": "span 4",
|
||||
"validation": "",
|
||||
"placeholder": "0.00",
|
||||
"conditionalLogic": {
|
||||
"action": "show",
|
||||
"enabled": false,
|
||||
"operator": "and",
|
||||
"conditions": []
|
||||
}
|
||||
"placeholder": "0.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"props": {
|
||||
"help": "Amount exceeding policy limits",
|
||||
"name": "over_budget_amount_display",
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"label": "Over Budget Amount (RM)",
|
||||
"width": "33.33%",
|
||||
"gridColumn": "span 4",
|
||||
"validation": "",
|
||||
"placeholder": "0.00",
|
||||
"conditionalLogic": {
|
||||
"action": "show",
|
||||
"enabled": false,
|
||||
"operator": "and",
|
||||
"conditions": []
|
||||
}
|
||||
"placeholder": "0.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -254,9 +236,18 @@
|
||||
"label": "Approval Decision",
|
||||
"width": "100%",
|
||||
"options": [
|
||||
{ "label": "✅ Approve Full Amount - Employee will receive the full claimed amount", "value": "approve_full" },
|
||||
{ "label": "⚠️ Approve Policy Limit Only - Employee will receive only the policy-allowed amount", "value": "approve_policy" },
|
||||
{ "label": "❌ Reject Claim - Claim will be denied entirely", "value": "reject" }
|
||||
{
|
||||
"label": "✅ Approve Full Amount - Employee will receive the full claimed amount",
|
||||
"value": "approve_full"
|
||||
},
|
||||
{
|
||||
"label": "⚠️ Approve Policy Limit Only - Employee will receive only the policy-allowed amount",
|
||||
"value": "approve_policy"
|
||||
},
|
||||
{
|
||||
"label": "❌ Reject Claim - Claim will be denied entirely",
|
||||
"value": "reject"
|
||||
}
|
||||
],
|
||||
"gridColumn": "span 12",
|
||||
"validation": "required",
|
||||
@ -286,8 +277,8 @@
|
||||
"conditions": [
|
||||
{
|
||||
"field": "manager_decision",
|
||||
"operator": "eq",
|
||||
"value": "approve_full"
|
||||
"value": "approve_full",
|
||||
"operator": "eq"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -337,10 +328,19 @@
|
||||
"label": "Additional Requirements",
|
||||
"width": "100%",
|
||||
"options": [
|
||||
{ "label": "Require additional documentation from employee", "value": "require_docs" },
|
||||
{ "label": "Schedule follow-up meeting with employee", "value": "follow_up" },
|
||||
{
|
||||
"label": "Require additional documentation from employee",
|
||||
"value": "require_docs"
|
||||
},
|
||||
{
|
||||
"label": "Schedule follow-up meeting with employee",
|
||||
"value": "follow_up"
|
||||
},
|
||||
{ "label": "Flag for finance team review", "value": "finance_review" },
|
||||
{ "label": "Add to employee's travel record for future reference", "value": "record_flag" }
|
||||
{
|
||||
"label": "Add to employee's travel record for future reference",
|
||||
"value": "record_flag"
|
||||
}
|
||||
],
|
||||
"gridColumn": "span 12",
|
||||
"validation": "",
|
||||
@ -405,4 +405,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
@ -234,7 +234,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"props": {
|
||||
"help": "Enter transportation costs (flights, trains, taxis, etc.)",
|
||||
"name": "transport_cost",
|
||||
@ -243,17 +243,11 @@
|
||||
"width": "50%",
|
||||
"gridColumn": "span 6",
|
||||
"validation": "required|min:0",
|
||||
"placeholder": "0.00",
|
||||
"conditionalLogic": {
|
||||
"action": "show",
|
||||
"enabled": false,
|
||||
"operator": "and",
|
||||
"conditions": []
|
||||
}
|
||||
"placeholder": "0.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"props": {
|
||||
"help": "Enter accommodation costs (hotels, lodging, etc.)",
|
||||
"name": "accommodation_cost",
|
||||
@ -262,17 +256,11 @@
|
||||
"width": "50%",
|
||||
"gridColumn": "span 6",
|
||||
"validation": "required|min:0",
|
||||
"placeholder": "0.00",
|
||||
"conditionalLogic": {
|
||||
"action": "show",
|
||||
"enabled": false,
|
||||
"operator": "and",
|
||||
"conditions": []
|
||||
}
|
||||
"placeholder": "0.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"props": {
|
||||
"help": "Enter meal and food expenses",
|
||||
"name": "meals_cost",
|
||||
@ -281,17 +269,11 @@
|
||||
"width": "50%",
|
||||
"gridColumn": "span 6",
|
||||
"validation": "required|min:0",
|
||||
"placeholder": "0.00",
|
||||
"conditionalLogic": {
|
||||
"action": "show",
|
||||
"enabled": false,
|
||||
"operator": "and",
|
||||
"conditions": []
|
||||
}
|
||||
"placeholder": "0.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"props": {
|
||||
"help": "Enter other miscellaneous expenses",
|
||||
"name": "other_cost",
|
||||
@ -300,33 +282,20 @@
|
||||
"width": "50%",
|
||||
"gridColumn": "span 6",
|
||||
"validation": "required|min:0",
|
||||
"placeholder": "0.00",
|
||||
"conditionalLogic": {
|
||||
"action": "show",
|
||||
"enabled": false,
|
||||
"operator": "and",
|
||||
"conditions": []
|
||||
}
|
||||
"placeholder": "0.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"props": {
|
||||
"help": "This will be automatically calculated from the above costs",
|
||||
"name": "total_cost_display",
|
||||
"type": "text",
|
||||
"type": "number",
|
||||
"label": "Total Claim Amount (RM)",
|
||||
"value": "0.00",
|
||||
"width": "100%",
|
||||
"gridColumn": "span 12",
|
||||
"help": "This will be automatically calculated from the above costs",
|
||||
"placeholder": "0.00",
|
||||
"validation": "",
|
||||
"conditionalLogic": {
|
||||
"action": "show",
|
||||
"enabled": false,
|
||||
"operator": "and",
|
||||
"conditions": []
|
||||
}
|
||||
"placeholder": "0.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -381,4 +350,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
@ -377,9 +377,9 @@
|
||||
"assignedUsers": [],
|
||||
"inputMappings": [
|
||||
{ "processVariable": "claimSummary", "formField": "claim_summary" },
|
||||
{ "processVariable": "totalCost", "formField": "total_cost" },
|
||||
{ "processVariable": "reimbursementAmount", "formField": "reimbursement_amount" },
|
||||
{ "processVariable": "overBudgetAmount", "formField": "over_budget_amount" }
|
||||
{ "processVariable": "totalCost", "formField": "total_cost_display" },
|
||||
{ "processVariable": "overBudgetAmount", "formField": "over_budget_amount_display" },
|
||||
{ "processVariable": "totalAllowed", "formField": "policy_limit_display" }
|
||||
],
|
||||
"assignmentType": "roles",
|
||||
"outputMappings": [
|
||||
|
@ -462,12 +462,24 @@ function substituteVariables(str, variables) {
|
||||
function applyInputMappings(nodeData, processVars, formData) {
|
||||
const { inputMappings = [] } = nodeData;
|
||||
console.log('[Workflow] Applying input mappings:', inputMappings);
|
||||
|
||||
|
||||
inputMappings.forEach(mapping => {
|
||||
const { processVariable, formField } = mapping;
|
||||
if (processVars[processVariable] !== undefined) {
|
||||
formData[formField] = processVars[processVariable];
|
||||
console.log(`[Workflow] Mapped ${processVariable} -> ${formField}:`, processVars[processVariable]);
|
||||
let value = processVars[processVariable];
|
||||
|
||||
// Check if the form field is a date field
|
||||
const fieldDef = nodeData.formComponents?.find(f => f.props?.name === formField);
|
||||
if (fieldDef && fieldDef.type === 'date' && value) {
|
||||
// Convert to YYYY-MM-DD
|
||||
const d = new Date(value);
|
||||
if (!isNaN(d)) {
|
||||
value = d.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
formData[formField] = value;
|
||||
console.log(`[Workflow] Mapped ${processVariable} -> ${formField}:`, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -915,6 +927,9 @@ watch(currentStep, async (newStep) => {
|
||||
}
|
||||
|
||||
// Update form store with form components and data for ComponentPreview
|
||||
// Clear previous form state to prevent stale/leaked fields
|
||||
formStore.formComponents = [];
|
||||
formStore.updatePreviewFormData({});
|
||||
if (currentForm.value?.formComponents) {
|
||||
formStore.formComponents = currentForm.value.formComponents;
|
||||
formStore.updatePreviewFormData(formData.value);
|
||||
@ -1330,6 +1345,7 @@ function getConditionGroupResult(conditionGroup, variables) {
|
||||
:form-components="currentForm.formComponents"
|
||||
:form-data="formData"
|
||||
@script-generated="handleConditionalLogicGenerated"
|
||||
:key="currentNode.value?.id"
|
||||
/>
|
||||
|
||||
<!-- Form Script Engine for conditional logic + custom script -->
|
||||
@ -1341,6 +1357,7 @@ function getConditionGroupResult(conditionGroup, variables) {
|
||||
:form-events="currentForm.formEvents || { onLoad: true, onFieldChange: true }"
|
||||
:script-mode="currentForm.scriptMode || 'safe'"
|
||||
@field-change="handleScriptFieldChange"
|
||||
:key="currentNode.value?.id"
|
||||
/>
|
||||
|
||||
<!-- FormKit form wrapper -->
|
||||
@ -1352,10 +1369,11 @@ function getConditionGroupResult(conditionGroup, variables) {
|
||||
:actions="false"
|
||||
:incomplete-message="false"
|
||||
validation-visibility="submit"
|
||||
:key="currentNode.value?.id"
|
||||
>
|
||||
<div class="grid grid-cols-12 gap-2">
|
||||
<template v-for="(component, index) in currentForm.formComponents" :key="index">
|
||||
<ComponentPreview
|
||||
<ComponentPreview
|
||||
:component="component"
|
||||
:is-preview="false"
|
||||
:field-states="fieldStates"
|
||||
|
Loading…
x
Reference in New Issue
Block a user