Enhance ComponentPreview and RepeatingTable Functionality

- Updated ComponentPreview.vue to improve the handling of repeating groups, including the addition of helper functions for field value retrieval and input handling.
- Enhanced the rendering of repeating groups with better structure and improved user experience, including dynamic item addition and removal.
- Modified RepeatingTable.vue to increase the maximum visible columns from 20 to 50, allowing for better data presentation and horizontal scrolling.
- Improved column width calculations and added a minimum table width to ensure proper layout and usability.
- Updated safeGetField.js to allow for optional warning suppression, enhancing flexibility in data access without unnecessary console warnings.
- Refined styles across components for better visual consistency and usability.
This commit is contained in:
Afiq 2025-08-07 14:17:51 +08:00
parent a14252a844
commit d0cef85b72
4 changed files with 480 additions and 227 deletions

View File

@ -23,12 +23,14 @@
</div>
<!-- Basic Input Types (including radio and checkbox) -->
<FormKit v-else-if="isInputType" :id="`preview-${component.id}`" :type="component.type" :name="component.props.name"
<FormKit v-else-if="isInputType" :id="`preview-${component.id}`" :type="component.type"
:name="getFieldName(component)"
:label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder"
:validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'"
:readonly="component.props.readonly || !isPreview"
:disabled="!isPreview || (component.props.readonly && ['select', 'searchSelect', 'checkbox', 'radio', 'switch'].includes(component.type))"
:options="component.props.options || undefined" :value="component.props.value || undefined"
:options="component.props.options || undefined"
:value="getFieldValue(component)"
: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"
@ -42,7 +44,9 @@
'readonly-checkbox': component.props.readonly && component.type === 'checkbox',
'readonly-radio': component.props.readonly && component.type === 'radio',
'readonly-switch': component.props.readonly && component.type === 'switch'
}" />
}"
@input="handleFieldInput(component, $event)"
@change="handleFieldChange(component, $event)" />
<!-- Heading -->
<div v-else-if="component.type === 'heading'" class="py-2">
@ -228,35 +232,59 @@
<!-- Preview mode - show functional repeating groups -->
<div v-else class="repeating-groups space-y-4">
<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">
<!-- Group items -->
<div v-for="(group, groupIndex) in (safeGetField(component.props.name, previewFormData, { warn: false }) || [])"
:key="`${component.props.name}-${groupIndex}`"
class="group-item border border-gray-200 rounded-md p-3 bg-gray-50 mb-4">
<!-- Item header -->
<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="(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 v-if="(safeGetField(component.props.name, previewFormData, { warn: false }) || []).length > (component.props.minItems || 1)"
type="button"
class="text-red-500 hover:text-red-700 text-sm"
@click="removeRepeatingGroupItem(component.props.name, groupIndex)">
{{ component.props.removeText || 'Buang' }}
</button>
</div>
<!-- Fields grid -->
<div class="grid grid-cols-12 gap-2">
<!-- Render children components for each group item -->
<template v-for="(child, childIndex) in component.props.children" :key="childIndex">
<div
class="form-component"
:style="{
gridColumn: child.props.gridColumn || 'span 6'
}"
>
<component-preview :component="child" :is-preview="true" />
</div>
</template>
<div v-for="(child, childIndex) in component.props.children"
:key="`field-${groupIndex}-${childIndex}`"
class="form-component"
:style="{ gridColumn: child.props.gridColumn || 'span 6' }">
<!-- Render FormKit field -->
<FormKit
:type="child.type"
:name="`${component.props.name}[${groupIndex}].${child.props.name}`"
:label="child.props.label"
:placeholder="child.props.placeholder"
:help="child.props.help"
:options="child.props.options"
:validation="child.props.validation"
:rows="child.type === 'textarea' ? (child.props.rows || 3) : undefined"
:model-value="getRepeatingGroupFieldValue(group, child.props.name, child.type)"
@update:model-value="updateRepeatingGroupField(component.props.name, groupIndex, child.props.name, $event)"
/>
</div>
</div>
</div>
<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">
<!-- Empty state -->
<div v-if="(safeGetField(component.props.name, previewFormData, { warn: false }) || []).length === 0"
class="text-center py-8 text-gray-500">
<p>No items yet. Click "{{ component.props.buttonText || 'Add Item' }}" to add the first item.</p>
</div>
<!-- Add button -->
<button v-if="(safeGetField(component.props.name, previewFormData, { warn: false }) || []).length < (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="addRepeatingGroupItem(component.props.name, component.props.children)">
<Icon name="material-symbols:add-circle-outline" class="w-4 h-4 mr-1" />
{{ component.props.buttonText || 'Add Item' }}
</button>
@ -270,7 +298,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">
({{ (safeGetField(component.props.name, previewFormData) || []).length }}/{{ component.props.maxItems || 20 }})
({{ (safeGetField(component.props.name, previewFormData, { warn: false }) || []).length }}/{{ component.props.maxItems || 20 }})
</span>
</label>
@ -289,7 +317,7 @@
</div>
<!-- Bulk Operations Toolbar (if enabled) -->
<div v-if="component.props.bulkOperations && (safeGetField(component.props.name, previewFormData) || []).length > 0"
<div v-if="component.props.bulkOperations && (safeGetField(component.props.name, previewFormData, { warn: false }) || []).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)"
@ -343,7 +371,7 @@
@change="toggleItemSelection(component.props.name, index)"
class="mr-2 h-4 w-4 rounded border-gray-300" />
<input :type="component.props.itemType || 'text'"
:value="safeGetField(component.props.name, previewFormData)[index]"
:value="safeGetField(component.props.name, previewFormData, { warn: false })[index]"
:placeholder="component.props.placeholder"
:class="getItemInputClasses(component.props.name, index, item)"
@blur="validateItem(component.props.name, index, item)"
@ -352,7 +380,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="(safeGetField(component.props.name, previewFormData)?.length || 0) > (component.props.minItems || 0)"
<button v-if="(safeGetField(component.props.name, previewFormData, { warn: false })?.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>
@ -363,7 +391,7 @@
{{ validationErrors[component.props.name] }}
</div>
<button v-if="(safeGetField(component.props.name, previewFormData)?.length || 0) < (component.props.maxItems || 20)"
<button v-if="(safeGetField(component.props.name, previewFormData, { warn: false })?.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">
@ -372,7 +400,7 @@
</button>
<!-- Export Button (if enabled) -->
<div v-if="component.props.exportFormat && (safeGetField(component.props.name, previewFormData) || []).length > 0"
<div v-if="component.props.exportFormat && (safeGetField(component.props.name, previewFormData, { warn: false }) || []).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">
@ -387,12 +415,14 @@
<!-- Repeating Table Component -->
<div v-else-if="component.type === 'repeating-table'" class="repeating-table-wrapper">
<RepeatingTable
:config="component.props"
:model-value="getTableData(component.props.name)"
:is-preview="isPreview"
@update:model-value="updateTableData"
/>
<div class="table-container-wrapper">
<RepeatingTable
:config="component.props"
:model-value="getTableData(component.props.name)"
:is-preview="isPreview"
@update:model-value="updateTableData"
/>
</div>
</div>
<!-- Button Component -->
@ -679,9 +709,11 @@ 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, nextTick } from 'vue';
import { onMounted, onUnmounted, watch, computed, nextTick, triggerRef, defineComponent, h } from 'vue';
import draggable from 'vuedraggable';
// RepeatingGroupContainer component removed - using template approach instead
const props = defineProps({
component: {
type: Object,
@ -703,11 +735,20 @@ const emit = defineEmits(['select-nested-component', 'form-data-updated']);
const formStore = useFormBuilderStore();
const previewFormData = computed(() => formStore.previewFormData || {});
// Clean up: Debug watcher removed - repeating groups working correctly
// Enhanced dynamic list functionality
const searchQuery = ref({});
const selectedItems = ref({});
const validationErrors = ref({});
// Force reactivity for repeating groups
const groupUpdateCounter = ref(0);
// Simplified reactive variables
const isInRepeatingGroup = ref(false);
const repeatingGroupContext = ref(null);
// New reactive state for form sections
const sectionDropStates = ref({});
@ -750,7 +791,7 @@ onMounted(() => {
validationErrors.value[listName] = '';
// Initialize form data with default items if they exist and form data is empty
const currentFormData = safeGetField(listName, formStore.previewFormData);
const currentFormData = safeGetField(listName, formStore.previewFormData, { warn: false });
const defaultItems = props.component.props.defaultItems;
if ((!currentFormData || currentFormData.length === 0) && defaultItems && defaultItems.length > 0) {
@ -767,7 +808,7 @@ onMounted(() => {
const groupName = props.component.props.name;
if (groupName) {
// Get current groups or initialize empty array
const currentGroups = safeGetField(groupName, formStore.previewFormData);
const currentGroups = safeGetField(groupName, formStore.previewFormData, { warn: false });
// If no groups exist and minItems is specified, create initial groups
if ((!currentGroups || currentGroups.length === 0) && props.component.props.minItems > 0) {
@ -794,6 +835,26 @@ onMounted(() => {
}
}
}
// Initialize repeating tables
if (props.component.type === 'repeating-table') {
const tableName = props.component.props.name;
if (tableName) {
// Get current table data or initialize empty array
const currentData = safeGetField(tableName, formStore.previewFormData, { warn: false });
// If no data exists, initialize it
if (!currentData || !Array.isArray(currentData)) {
const initialData = Array.isArray(props.component.props.defaultData) ? [...props.component.props.defaultData] : [];
nextTick(() => {
const updatedData = { ...formStore.previewFormData, [tableName]: initialData };
formStore.updatePreviewFormData(updatedData);
console.log('[ComponentPreview] Initialized repeating table data:', tableName, initialData);
});
}
}
}
});
// Watch for changes to component props, especially defaultItems
@ -824,7 +885,7 @@ watch(() => props.component.props.minItems, (newMinItems, oldMinItems) => {
const groupName = props.component.props.name;
if (!groupName || newMinItems === oldMinItems) return;
const currentGroups = safeGetField(groupName, formStore.previewFormData);
const currentGroups = safeGetField(groupName, formStore.previewFormData, { warn: false });
const minItems = newMinItems || 1;
// If current groups are fewer than minItems, add missing groups
@ -853,29 +914,12 @@ watch(() => props.component.props.minItems, (newMinItems, oldMinItems) => {
}
}, { deep: true, immediate: true });
// Controlled update methods to prevent circular reactivity
const updateGroupField = (groupName, groupIndex, fieldName, newValue) => {
if (!props.isPreview) return;
const currentGroups = [...(safeGetField(groupName, formStore.previewFormData) || [])];
if (!currentGroups[groupIndex]) return;
// Only update if value actually changed
if (currentGroups[groupIndex][fieldName] === newValue) return;
currentGroups[groupIndex][fieldName] = newValue;
nextTick(() => {
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
formStore.updatePreviewFormData(updatedData);
emit('form-data-updated', updatedData);
});
};
// Legacy updateGroupField removed - now handled by RepeatingGroupContainer
const updateListItem = (listName, index, newValue) => {
if (!props.isPreview) return;
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
const currentItems = [...(safeGetField(listName, formStore.previewFormData, { warn: false }) || [])];
if (currentItems[index] === newValue) return; // No change
// Validate and handle duplicates
@ -890,63 +934,7 @@ const updateListItem = (listName, index, newValue) => {
});
};
// Repeating group and dynamic list functionality
const addGroupItem = () => {
if (!props.isPreview) return;
const groupName = props.component.props.name;
if (!groupName) return;
// Get current groups or initialize empty array
const currentGroups = safeGetField(groupName, formStore.previewFormData) || [];
// Create a new empty group
const newGroup = {};
// Add fields from configuration
if (props.component.props.fields) {
props.component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
// Add the new group to the list
currentGroups.push(newGroup);
// Update the form data
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
formStore.updatePreviewFormData(updatedData);
// Also emit an event to notify parent components about the data change
emit('form-data-updated', updatedData);
};
const removeGroupItem = (index) => {
if (!props.isPreview) return;
const groupName = props.component.props.name;
if (!groupName) return;
// Get current groups
const currentGroups = [...(safeGetField(groupName, formStore.previewFormData) || [])];
// Check if we can remove this item (respect minimum items)
const minItems = props.component.props.minItems || 1;
if (currentGroups.length <= minItems) {
return;
}
// Remove the group at the specified index
currentGroups.splice(index, 1);
// Update the form data
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
formStore.updatePreviewFormData(updatedData);
// Also emit an event to notify parent components about the data change
// This is important for FormKit integration
emit('form-data-updated', updatedData);
};
// Legacy functions removed - now handled by RepeatingGroupContainer
const addListItem = () => {
if (!props.isPreview) return;
@ -955,7 +943,7 @@ const addListItem = () => {
if (!listName) return;
// Get current items or initialize empty array
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
const currentItems = [...(safeGetField(listName, formStore.previewFormData, { warn: false }) || [])];
// Add an empty item
currentItems.push('');
@ -972,7 +960,7 @@ const removeListItem = (index) => {
if (!listName) return;
// Get current items
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
const currentItems = [...(safeGetField(listName, formStore.previewFormData, { warn: false }) || [])];
// Remove the item at the specified index
currentItems.splice(index, 1);
@ -1041,7 +1029,7 @@ const validateItem = (listName, index, value) => {
const checkDuplicates = (listName, newValue, currentIndex) => {
if (props.component.props.allowDuplicates) return true;
const currentItems = safeGetField(listName, formStore.previewFormData) || [];
const currentItems = safeGetField(listName, formStore.previewFormData, { warn: false }) || [];
const duplicateIndex = currentItems.findIndex((item, index) =>
index !== currentIndex && item.toLowerCase() === newValue.toLowerCase()
);
@ -1080,7 +1068,7 @@ const getItemInputClasses = (listName, index, value) => {
// Filter items based on search query
const getFilteredItems = (listName) => {
const items = safeGetField(listName, formStore.previewFormData) || [];
const items = safeGetField(listName, formStore.previewFormData, { warn: false }) || [];
const query = searchQuery.value[listName];
if (!query || !props.component.props.enableSearch) {
@ -1113,13 +1101,13 @@ const toggleItemSelection = (listName, index) => {
};
const isAllSelected = (listName) => {
const items = safeGetField(listName, formStore.previewFormData) || [];
const items = safeGetField(listName, formStore.previewFormData, { warn: false }) || [];
const selected = selectedItems.value[listName] || [];
return items.length > 0 && selected.length === items.length;
};
const toggleSelectAll = (listName) => {
const items = safeGetField(listName, formStore.previewFormData) || [];
const items = safeGetField(listName, formStore.previewFormData, { warn: false }) || [];
if (isAllSelected(listName)) {
selectedItems.value[listName] = [];
@ -1130,7 +1118,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 = [...(safeGetField(listName, formStore.previewFormData) || [])];
const currentItems = [...(safeGetField(listName, formStore.previewFormData, { warn: false }) || [])];
const selected = selectedItems.value[listName] || [];
// Sort indices in descending order to avoid index shifting issues
@ -1152,7 +1140,7 @@ const deleteSelectedItems = (listName) => {
// Import/Export functionality
const exportItems = (listName, format) => {
const items = safeGetField(listName, formStore.previewFormData) || [];
const items = safeGetField(listName, formStore.previewFormData, { warn: false }) || [];
let content = '';
let filename = `${listName}_items`;
let mimeType = 'text/plain';
@ -1203,7 +1191,7 @@ const showImportModal = (listName) => {
} else {
importedItems = content.split('\n').filter(Boolean);
}
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
const currentItems = [...(safeGetField(listName, formStore.previewFormData, { warn: false }) || [])];
const newItems = [...currentItems, ...importedItems];
const maxItems = props.component.props.maxItems || 20;
if (newItems.length > maxItems) {
@ -1344,41 +1332,170 @@ const updateListItems = (listName, newItems) => {
const getTableData = (tableName) => {
if (!tableName) return [];
// Directly check the form store without using safeGetField to avoid warnings
const formData = formStore.previewFormData || {};
// Use safeGetField for consistent data access
const currentData = safeGetField(tableName, formStore.previewFormData, { warn: false });
console.log('[ComponentPreview] getTableData:', tableName, 'currentData:', currentData);
// If field exists and is an array, return it
if (formData.hasOwnProperty(tableName) && Array.isArray(formData[tableName])) {
return formData[tableName];
if (Array.isArray(currentData)) {
return currentData;
}
// If data doesn't exist, initialize it immediately (no nextTick needed for initial render)
// If data doesn't exist, initialize it immediately
const initialData = [];
const updatedFormData = { ...formData, [tableName]: initialData };
const updatedFormData = { ...formStore.previewFormData, [tableName]: initialData };
formStore.updatePreviewFormData(updatedFormData);
console.log('[ComponentPreview] getTableData: initialized new data for:', tableName);
return initialData;
};
// Simplified helper functions for regular fields only
const getFieldName = (component) => component.props.name;
const getFieldValue = (component) => {
const fieldName = component.props.name;
const formData = formStore.previewFormData;
// Silent check - don't trigger warnings for regular field access
if (formData && formData.hasOwnProperty(fieldName)) {
const value = formData[fieldName];
if (value !== undefined && value !== null) {
return value;
}
}
// Return component default value if no form data exists
return component.props.value || '';
};
const handleFieldInput = (component, event) => {
if (!props.isPreview) return;
const fieldName = component.props.name;
const newValue = event;
// Simple update for regular fields only
const updatedData = { ...formStore.previewFormData, [fieldName]: newValue };
formStore.updatePreviewFormData(updatedData);
emit('form-data-updated', updatedData);
};
const handleFieldChange = (component, event) => {
handleFieldInput(component, event);
};
// Helper function to safely get field values for repeating groups without warnings
const getRepeatingGroupFieldValue = (group, fieldName, fieldType) => {
if (!group || typeof group !== 'object') {
return getDefaultValueForType(fieldType);
}
if (group.hasOwnProperty(fieldName)) {
const value = group[fieldName];
if (value === undefined || value === null) {
return getDefaultValueForType(fieldType);
}
return value;
}
return getDefaultValueForType(fieldType);
};
// Helper function to get default values based on field type
const getDefaultValueForType = (fieldType) => {
switch (fieldType) {
case 'number':
return 0;
case 'checkbox':
return [];
case 'select':
return '';
default:
return '';
}
};
// Simple repeating group functions
const addRepeatingGroupItem = (groupName, children) => {
const currentData = safeGetField(groupName, formStore.previewFormData, { warn: false }) || [];
const newItem = {};
// Initialize fields from children
if (children) {
children.forEach(child => {
if (child.props && child.props.name) {
// Use the same default value logic as getDefaultValueForType
if (child.type === 'select' && child.props.options && child.props.options.length > 0) {
newItem[child.props.name] = child.props.options[0].value || '';
} else {
newItem[child.props.name] = getDefaultValueForType(child.type);
}
}
});
}
const newData = [...currentData, newItem];
const updatedFormData = { ...formStore.previewFormData, [groupName]: newData };
formStore.updatePreviewFormData(updatedFormData);
emit('form-data-updated', updatedFormData);
};
const removeRepeatingGroupItem = (groupName, index) => {
const currentData = safeGetField(groupName, formStore.previewFormData, { warn: false }) || [];
const newData = [...currentData];
newData.splice(index, 1);
const updatedFormData = { ...formStore.previewFormData, [groupName]: newData };
formStore.updatePreviewFormData(updatedFormData);
emit('form-data-updated', updatedFormData);
};
const updateRepeatingGroupField = (groupName, groupIndex, fieldName, value) => {
const currentData = safeGetField(groupName, formStore.previewFormData, { warn: false }) || [];
const newData = [...currentData];
if (!newData[groupIndex]) {
newData[groupIndex] = {};
}
newData[groupIndex][fieldName] = value;
const updatedFormData = { ...formStore.previewFormData, [groupName]: newData };
formStore.updatePreviewFormData(updatedFormData);
emit('form-data-updated', updatedFormData);
};
// Duplicate RepeatingGroupContainer definition removed
// Update table data for repeating-table component
const updateTableData = (newData) => {
const tableName = props.component.props.name;
if (!tableName) return;
console.log('[ComponentPreview] updateTableData:', tableName, 'newData:', newData);
// Ensure newData is always an array
const safeNewData = Array.isArray(newData) ? newData : [];
// Check if data actually changed to prevent unnecessary updates
const currentData = safeGetField(tableName, formStore.previewFormData) || [];
const currentData = safeGetField(tableName, formStore.previewFormData, { warn: false }) || [];
const currentDataStr = JSON.stringify(currentData);
const newDataStr = JSON.stringify(safeNewData);
if (currentDataStr === newDataStr) return;
if (currentDataStr === newDataStr) {
console.log('[ComponentPreview] updateTableData: no change detected, skipping update');
return;
}
nextTick(() => {
const updatedFormData = { ...formStore.previewFormData, [tableName]: safeNewData };
formStore.updatePreviewFormData(updatedFormData);
console.log('[ComponentPreview] updateTableData: updated form data for:', tableName, 'new data:', safeNewData);
// Emit the change for workflow page to sync with its local formData
emit('form-data-updated', updatedFormData);
});
@ -1386,8 +1503,8 @@ const updateTableData = (newData) => {
// Form Section Component
const toggleSectionCollapse = (sectionId) => {
// Find the section component and toggle its collapsed state
const section = formStore.formComponents.find(comp => comp.id === sectionId);
// Find the section component at any nesting level
const section = findContainerRecursively(sectionId);
if (section) {
section.props.collapsed = !section.props.collapsed;
formStore.updateComponent(section);
@ -2066,6 +2183,15 @@ onMounted(() => {
.repeating-table-wrapper{
margin-bottom: 0.5rem !important;
width: 100%;
min-width: 0; /* Allow shrinking */
overflow: hidden; /* Prevent table from breaking layout */
}
.table-container-wrapper {
width: 100%;
min-width: 0; /* Allow shrinking */
/* This is where the horizontal scrolling happens */
}
/* Form Section Component */

View File

@ -56,22 +56,22 @@
<!-- Data Table -->
<div class="table-container">
<!-- Performance warning for too many columns -->
<div v-if="config.columns.length > 20" class="performance-warning">
<div v-if="config.columns.length > 50" class="performance-warning">
<div class="warning-content">
<Icon name="heroicons:information-circle" class="warning-icon" />
<div class="warning-text">
<p class="warning-title">Performance Notice</p>
<p class="warning-message">
This table has {{ config.columns.length }} columns. For optimal performance, only the first 20 columns are displayed.
Consider reducing the number of columns for better user experience.
This table has {{ config.columns.length }} columns. For optimal performance, only the first 50 columns are displayed.
The table is scrollable horizontally to view all data. Consider reducing the number of columns for better user experience.
</p>
</div>
</div>
</div>
<div v-if="paginatedData.length > 0" class="table-wrapper">
<div class="table-container" ref="tableContainer">
<table class="data-table">
<div class="table-scroll-container" ref="tableContainer">
<table class="data-table" :style="{ minWidth: calculateMinTableWidth }">
<thead class="table-header">
<tr>
<th v-if="config.showRowNumbers" class="row-number-header">#</th>
@ -79,7 +79,7 @@
v-for="column in visibleColumns"
:key="column.name"
class="column-header"
:style="{ width: getColumnWidth(column) }"
:style="{ width: getColumnWidth(column), minWidth: getColumnWidth(column) }"
>
<div class="column-header-content">
<span class="header-text">{{ column.label }}</span>
@ -103,7 +103,7 @@
v-for="column in visibleColumns"
:key="column.name"
class="data-cell"
:style="{ width: getColumnWidth(column) }"
:style="{ width: getColumnWidth(column), minWidth: getColumnWidth(column) }"
>
<div class="cell-content">
<SimpleCellValue
@ -415,33 +415,55 @@ const filteredData = computed(() => {
// Column virtualization for large datasets
const visibleColumns = computed(() => {
const columns = props.config.columns
const maxColumns = 20 // Limit visible columns for performance
const maxColumns = 50 // Increased limit for better user experience with scrolling
if (columns.length <= maxColumns) {
return columns
}
// Return first 20 columns for performance
// Return first 50 columns for performance, but allow scrolling
return columns.slice(0, maxColumns)
})
// Column width calculation
const getColumnWidth = (column) => {
const baseWidth = 150
const baseWidth = 150 // Increased base width
const typeWidths = {
text: 150,
number: 100,
email: 200,
email: 180,
tel: 120,
date: 120,
time: 100,
url: 200,
url: 180,
select: 150,
checkbox: 80,
textarea: 200
}
return `${typeWidths[column.type] || baseWidth}px`
// For tables with many columns, still use reasonable widths
const totalColumns = props.config.columns.length
if (totalColumns > 10) {
const adjustedWidths = {
text: 120,
number: 90,
email: 160,
tel: 110,
date: 110,
time: 90,
url: 160,
select: 120,
checkbox: 70,
textarea: 160
}
const width = `${adjustedWidths[column.type] || baseWidth}px`
console.log(`[RepeatingTable] Column ${column.name} width: ${width}`)
return width
}
const width = `${typeWidths[column.type] || baseWidth}px`
console.log(`[RepeatingTable] Column ${column.name} width: ${width}`)
return width
}
// Record key generation for better Vue rendering
@ -676,6 +698,51 @@ const goToPage = (page) => {
currentPage.value = page
}
// Calculate minimum table width based on columns
const calculateMinTableWidth = computed(() => {
if (!props.config.columns) return 'auto'
let totalWidth = 0
// Add row number column width if enabled
if (props.config.showRowNumbers) {
totalWidth += 60 // Row number column width
}
// Add column widths
visibleColumns.value.forEach(column => {
const width = parseInt(getColumnWidth(column))
totalWidth += width
})
// Add actions column width if enabled
if (showActions.value) {
totalWidth += 100 // Actions column width
}
// Add some padding for borders and spacing
totalWidth += 40
console.log('[RepeatingTable] Calculated minimum width:', totalWidth)
return `${totalWidth}px`
})
// Debug table width on mount
onMounted(() => {
nextTick(() => {
if (tableContainer.value) {
const table = tableContainer.value.querySelector('.data-table')
if (table) {
console.log('[RepeatingTable] Table width:', table.offsetWidth)
console.log('[RepeatingTable] Container width:', tableContainer.value.offsetWidth)
console.log('[RepeatingTable] Scroll width:', table.scrollWidth)
console.log('[RepeatingTable] Has horizontal scroll:', table.scrollWidth > tableContainer.value.offsetWidth)
console.log('[RepeatingTable] Calculated min width:', calculateMinTableWidth.value)
}
}
})
})
// Cleanup on unmount
onUnmounted(() => {
columnCache.value.clear()
@ -686,21 +753,33 @@ onUnmounted(() => {
<style scoped>
/* Table Container with Sticky Header */
.table-container {
overflow: hidden;
background: white;
max-height: 500px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
width: 100%;
}
.table-scroll-container {
overflow-x: auto !important;
overflow-y: auto;
max-height: 500px;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
/* Ensure horizontal scrolling is always available */
width: 100%;
position: relative;
}
.data-table {
table-layout: fixed;
table-layout: auto; /* Changed from fixed to auto for better column sizing */
width: 100%;
border-collapse: collapse;
border-spacing: 0;
margin: 0;
/* Ensure table is wide enough to trigger scrolling */
white-space: nowrap;
/* Force minimum width to ensure scrolling */
min-width: 100%;
}
/* Sticky Header */
@ -718,42 +797,47 @@ onUnmounted(() => {
}
/* Custom scrollbar for webkit browsers */
.table-container::-webkit-scrollbar {
.table-scroll-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-container::-webkit-scrollbar-track {
.table-scroll-container::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb {
.table-scroll-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb:hover {
.table-scroll-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Horizontal scrollbar styling */
.table-scroll-container::-webkit-scrollbar:horizontal {
height: 8px;
}
.column-header,
.data-cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
padding: 8px 12px;
vertical-align: middle;
/* Remove text truncation to allow full content display */
}
.cell-content {
overflow: hidden;
text-overflow: ellipsis;
/* Allow content to expand naturally */
min-width: max-content;
}
.cell-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Remove text truncation to show full content */
}
/* Virtual scrolling optimizations */
@ -801,7 +885,7 @@ onUnmounted(() => {
/* Responsive optimizations */
@media (max-width: 768px) {
.table-content {
.table-scroll-container {
max-height: 400px;
}
@ -813,7 +897,7 @@ onUnmounted(() => {
}
@media (max-width: 480px) {
.table-content {
.table-scroll-container {
max-height: 300px;
}
@ -823,6 +907,35 @@ onUnmounted(() => {
max-width: 100px;
}
}
/* Enhanced scrolling for wide tables */
.table-scroll-container {
position: relative;
}
.table-scroll-container::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 20px;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.8));
pointer-events: none;
z-index: 1;
}
.table-scroll-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 20px;
background: linear-gradient(to left, transparent, rgba(255, 255, 255, 0.8));
pointer-events: none;
z-index: 1;
}
.repeating-table-container {
@apply border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm w-full;
min-width: 0;

View File

@ -1,5 +1,5 @@
// Utility to safely get a field value from a form data object
export function safeGetField(field, formData) {
export function safeGetField(field, formData, options = {}) {
if (formData && Object.prototype.hasOwnProperty.call(formData, field)) {
const value = formData[field];
// If the value is undefined or null, return empty string for backward compatibility
@ -8,9 +8,14 @@ export function safeGetField(field, formData) {
}
return value;
}
if (process.env.NODE_ENV !== 'production') {
// Only warn in development
console.warn(`Field '${field}' is missing or inaccessible.`);
// Only warn for specific cases or when explicitly requested
if (process.env.NODE_ENV !== 'production' && options.warn !== false) {
// Don't warn for newly added components or fields that haven't been initialized yet
const isNewField = field && (field.includes('_') && /\d+$/.test(field));
if (!isNewField) {
console.warn(`Field '${field}' is missing or inaccessible.`);
}
}
return '';
}

View File

@ -285,8 +285,7 @@
ref="previewForm"
type="form"
@submit="handlePreviewSubmit"
:actions="false"
v-model="previewFormData"
:actions="false"
>
<div
class="grid-preview-container"
@ -2069,43 +2068,43 @@ const handleJsonImport = (event) => {
// Ensure all required info display properties
processedProps = {
title: processedProps.title || 'Information',
name: processedDefaultProps.name || `info_display_${index + 1}`,
help: processedDefaultProps.help || '',
layout: processedDefaultProps.layout || 'vertical',
showBorder: processedDefaultProps.showBorder !== undefined ? processedDefaultProps.showBorder : true,
backgroundColor: processedDefaultProps.backgroundColor || '#f8fafc',
fields: Array.isArray(processedDefaultProps.fields) ? processedDefaultProps.fields : [
name: processedProps.name || `info_display_${index + 1}`,
help: processedProps.help || '',
layout: processedProps.layout || 'vertical',
showBorder: processedProps.showBorder !== undefined ? processedProps.showBorder : true,
backgroundColor: processedProps.backgroundColor || '#f8fafc',
fields: Array.isArray(processedProps.fields) ? processedProps.fields : [
{ label: 'Info Item', value: 'Value', key: 'item_1' }
],
...processedDefaultProps
...processedProps
};
break;
case 'file':
// Ensure all required file upload properties
processedProps = {
label: processedDefaultProps.label || 'File Upload',
name: processedDefaultProps.name || `file_upload_${index + 1}`,
help: processedDefaultProps.help || 'Upload a file',
accept: processedDefaultProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
...processedDefaultProps
label: processedProps.label || 'File Upload',
name: processedProps.name || `file_upload_${index + 1}`,
help: processedProps.help || 'Upload a file',
accept: processedProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
...processedProps
};
break;
case 'heading':
// Ensure all required heading properties
processedProps = {
value: processedDefaultProps.value || 'Heading',
level: processedDefaultProps.level || 2,
...processedDefaultProps
value: processedProps.value || 'Heading',
level: processedProps.level || 2,
...processedProps
};
break;
case 'paragraph':
// Ensure all required paragraph properties
processedProps = {
value: processedDefaultProps.value || 'Paragraph text',
...processedDefaultProps
value: processedProps.value || 'Paragraph text',
...processedProps
};
break;
@ -2113,7 +2112,7 @@ const handleJsonImport = (event) => {
case 'radio':
case 'checkbox':
// Ensure options array exists
if (!Array.isArray(processedDefaultProps.options) || processedDefaultProps.options.length === 0) {
if (!Array.isArray(processedProps.options) || processedProps.options.length === 0) {
processedDefaultProps.options = [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' }
@ -2537,18 +2536,13 @@ const saveFormSettings = () => {
// Preview form data for script interactions
const previewFormData = ref({});
// Initialize preview form data with default values
watchEffect(() => {
console.log('[FormBuilder] watchEffect for previewFormData initialization. Current form components:', formStore.formComponents.length);
const existingFormData = { ...previewFormData.value }; // Preserve current user inputs
let newDefaults = {};
let hasNewComponents = false;
formStore.formComponents.forEach(component => {
// Helper function to process nested components recursively
const processNestedComponents = (components, existingFormData, newDefaults, hasNewComponents) => {
components.forEach(component => {
if (component.props.name) {
// If field is not already in existingFormData, it's a new component or needs initialization
if (!existingFormData.hasOwnProperty(component.props.name)) {
hasNewComponents = true;
hasNewComponents.value = true;
// Set default values based on component type
switch (component.type) {
case 'checkbox':
@ -2620,25 +2614,48 @@ watchEffect(() => {
}
}
}
// Process nested components if this component has children
if (component.props.children && Array.isArray(component.props.children)) {
processNestedComponents(component.props.children, existingFormData, newDefaults, hasNewComponents);
}
});
};
// Initialize preview form data with default values
watchEffect(() => {
const existingFormData = { ...previewFormData.value }; // Preserve current user inputs
let newDefaults = {};
let hasNewComponents = { value: false };
// Process all components including nested ones
processNestedComponents(formStore.formComponents, existingFormData, newDefaults, hasNewComponents);
// Only update previewFormData if it's the initial load (empty) or new components were added that need defaults
// This prevents overwriting user input when existing components change their props (which also triggers this watchEffect)
const isInitialLoad = Object.keys(previewFormData.value).length === 0 && Object.keys(newDefaults).length > 0;
if (isInitialLoad || hasNewComponents) {
console.log('[FormBuilder] Initializing/merging preview form data. Initial load:', isInitialLoad, 'Has new components:', hasNewComponents);
previewFormData.value = { ...existingFormData, ...newDefaults };
console.log('[FormBuilder] Preview form data after init/merge:', previewFormData.value);
} else {
console.log('[FormBuilder] Skipping full previewFormData re-initialization to preserve user input.');
// CRITICAL FIX: Don't reinitialize if we already have data for repeating groups/arrays
// This prevents data loss when users are editing repeating group items
const hasExistingArrayData = Object.keys(existingFormData).some(key => Array.isArray(existingFormData[key]) && existingFormData[key].length > 0);
if (isInitialLoad || (hasNewComponents.value && !hasExistingArrayData)) {
// Smart merge: preserve existing array data, only add truly new defaults
const smartMerge = { ...existingFormData };
// Only add defaults for fields that don't exist at all
Object.keys(newDefaults).forEach(key => {
if (!smartMerge.hasOwnProperty(key)) {
smartMerge[key] = newDefaults[key];
}
});
previewFormData.value = smartMerge;
}
});
// Handle script-driven field changes
const handleScriptFieldChange = ({ fieldName, value }) => {
console.log('[FormBuilder] Script field change:', fieldName, '=', value);
// Update the reactive form data
const newFormData = {
...previewFormData.value,
@ -2652,15 +2669,12 @@ const handleScriptFieldChange = ({ fieldName, value }) => {
// Try to force FormKit form to update
nextTick(() => {
console.log('[FormBuilder] Updated form data:', newFormData);
// Try to access the FormKit form node and update it directly
if (previewForm.value && previewForm.value.node) {
try {
previewForm.value.node.input(newFormData);
console.log('[FormBuilder] Force updated FormKit form');
} catch (error) {
console.warn('[FormBuilder] Could not force update FormKit form:', error);
// Silent fallback
}
}
});
@ -2669,26 +2683,16 @@ const handleScriptFieldChange = ({ fieldName, value }) => {
// Handle script-driven field validation
const handleScriptFieldValidate = ({ fieldName }) => {
// Could integrate with FormKit validation here
console.log(`Validating field: ${fieldName}`);
};
// Handle FormKit form input events
const handleFormKitInput = (formData, node) => {
console.log('[FormBuilder] FormKit input event received!');
console.log('[FormBuilder] FormKit formData:', JSON.parse(JSON.stringify(formData)));
console.log('[FormBuilder] Current previewFormData before update:', JSON.parse(JSON.stringify(previewFormData.value)));
// Update our reactive form data - this should trigger the FormScriptEngine watcher
const oldPreviewData = { ...previewFormData.value };
previewFormData.value = { ...formData };
console.log('[FormBuilder] Updated previewFormData to:', JSON.parse(JSON.stringify(previewFormData.value)));
console.log('[FormBuilder] Did previewFormData actually change?', JSON.stringify(oldPreviewData) !== JSON.stringify(previewFormData.value));
// Make form data accessible to component previews
formStore.updatePreviewFormData(previewFormData.value);
console.log('[FormBuilder] FormStore preview data updated to:', JSON.parse(JSON.stringify(formStore.previewFormData)));
};
// Watch for changes in previewFormData to trigger FormScriptEngine
@ -2702,9 +2706,13 @@ watch(() => previewFormData.value, (newData, oldData) => {
return; // No actual change, skip update
}
console.log('[FormBuilder] previewFormData watcher triggered!');
console.log('[FormBuilder] New data:', newDataStr);
console.log('[FormBuilder] Old data:', oldDataStr);
// CRITICAL FIX: Don't sync back to store if we have any array data at all
// ComponentPreview.vue manages its own array data directly with the store
const hasArrayData = Object.keys(newData).some(key => Array.isArray(newData[key]));
if (hasArrayData) {
return;
}
// Use nextTick to avoid synchronous updates that can cause recursion
nextTick(() => {
@ -2712,7 +2720,6 @@ watch(() => previewFormData.value, (newData, oldData) => {
const currentDataStr = JSON.stringify(previewFormData.value);
if (currentDataStr === newDataStr) {
formStore.updatePreviewFormData(newData);
console.log('[FormBuilder] FormStore preview data updated to:', JSON.parse(JSON.stringify(formStore.previewFormData)));
}
});
}, { deep: true, immediate: false });
@ -3379,6 +3386,8 @@ const handleFormRestored = (restoredForm) => {
console.log('Form restored:', restoredForm);
// You might want to update the form state or show a success message
};
</script>
<style scoped>