Refactor ComponentPreview and RepeatingTable for Improved Data Handling and Performance

- Updated ComponentPreview.vue to enhance data binding for form fields, ensuring controlled updates to prevent circular reactivity.
- Introduced new methods for updating group fields and list items, improving data management and validation.
- Enhanced RepeatingTable.vue by replacing the LazyCellValue component with a SimpleCellValue for immediate data display, optimizing rendering performance.
- Improved table structure and styling for better user experience, including sticky headers and custom scrollbar styles.
- Added logic to initialize repeating tables with default data in form builder, ensuring consistent data handling across components.
- Implemented checks to prevent unnecessary updates in form data, enhancing overall application performance.
This commit is contained in:
Afiq 2025-08-07 09:42:20 +08:00
parent eab2ca3647
commit f024cc91dd
4 changed files with 243 additions and 86 deletions

View File

@ -184,7 +184,8 @@
<template v-for="(field, fieldIndex) in component.props.fields" :key="fieldIndex">
<FormKit :type="field.type" :label="field.label" :placeholder="field.placeholder"
:name="`${component.props.name}.${groupIndex}.${field.name}`" :options="field.options"
v-model="group[field.name]" />
:value="group[field.name]"
@input="updateGroupField(component.props.name, groupIndex, field.name, $event)" />
</template>
</div>
</div>
@ -278,11 +279,12 @@
: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="safeGetField(component.props.name, previewFormData)[index]"
<input :type="component.props.itemType || 'text'"
:value="safeGetField(component.props.name, previewFormData)[index]"
:placeholder="component.props.placeholder"
:class="getItemInputClasses(component.props.name, index, item)"
@blur="validateItem(component.props.name, index, item)"
@input="handleItemInput(component.props.name, index, $event.target.value)" />
@input="updateListItem(component.props.name, index, $event.target.value)" />
<div v-if="component.props.enableSorting"
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" />
@ -324,7 +326,7 @@
<div v-else-if="component.type === 'repeating-table'" class="repeating-table-wrapper">
<RepeatingTable
:config="component.props"
:model-value="safeGetField(component.props.name, previewFormData) || []"
:model-value="getTableData(component.props.name)"
:is-preview="isPreview"
@update:model-value="updateTableData"
/>
@ -646,8 +648,10 @@ onMounted(() => {
const defaultItems = props.component.props.defaultItems;
if ((!currentFormData || currentFormData.length === 0) && defaultItems && defaultItems.length > 0) {
const updatedData = { ...formStore.previewFormData, [listName]: [...defaultItems] };
formStore.updatePreviewFormData(updatedData);
nextTick(() => {
const updatedData = { ...formStore.previewFormData, [listName]: [...defaultItems] };
formStore.updatePreviewFormData(updatedData);
});
}
}
}
@ -677,8 +681,10 @@ onMounted(() => {
initialGroups.push(newGroup);
}
const updatedData = { ...formStore.previewFormData, [groupName]: initialGroups };
formStore.updatePreviewFormData(updatedData);
nextTick(() => {
const updatedData = { ...formStore.previewFormData, [groupName]: initialGroups };
formStore.updatePreviewFormData(updatedData);
});
}
}
}
@ -690,11 +696,19 @@ watch(() => props.component.props.defaultItems, (newDefaultItems, oldDefaultItem
const listName = props.component.props.name;
if (!listName) return;
// Check if defaultItems actually changed to prevent unnecessary updates
const newItemsStr = JSON.stringify(newDefaultItems);
const oldItemsStr = JSON.stringify(oldDefaultItems);
if (newItemsStr === oldItemsStr) return;
// Always update when defaultItems change, regardless of current form data
const items = newDefaultItems || [];
const updatedData = { ...formStore.previewFormData, [listName]: [...items] };
formStore.updatePreviewFormData(updatedData);
nextTick(() => {
const updatedData = { ...formStore.previewFormData, [listName]: [...items] };
formStore.updatePreviewFormData(updatedData);
});
}
}, { deep: true, immediate: true });
@ -702,7 +716,7 @@ watch(() => props.component.props.defaultItems, (newDefaultItems, oldDefaultItem
watch(() => props.component.props.minItems, (newMinItems, oldMinItems) => {
if (props.component.type === 'repeating-group') {
const groupName = props.component.props.name;
if (!groupName) return;
if (!groupName || newMinItems === oldMinItems) return;
const currentGroups = safeGetField(groupName, formStore.previewFormData);
const minItems = newMinItems || 1;
@ -725,12 +739,51 @@ watch(() => props.component.props.minItems, (newMinItems, oldMinItems) => {
updatedGroups.push(newGroup);
}
const updatedData = { ...formStore.previewFormData, [groupName]: updatedGroups };
formStore.updatePreviewFormData(updatedData);
nextTick(() => {
const updatedData = { ...formStore.previewFormData, [groupName]: updatedGroups };
formStore.updatePreviewFormData(updatedData);
});
}
}
}, { 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);
});
};
const updateListItem = (listName, index, newValue) => {
if (!props.isPreview) return;
const currentItems = [...(safeGetField(listName, formStore.previewFormData) || [])];
if (currentItems[index] === newValue) return; // No change
// Validate and handle duplicates
if (!checkDuplicates(listName, newValue, index)) return;
if (!validateItem(listName, index, newValue)) return;
currentItems[index] = newValue;
nextTick(() => {
const updatedData = { ...formStore.previewFormData, [listName]: currentItems };
formStore.updatePreviewFormData(updatedData);
});
};
// Repeating group and dynamic list functionality
const addGroupItem = () => {
if (!props.isPreview) return;
@ -1181,13 +1234,48 @@ const updateListItems = (listName, newItems) => {
formStore.updatePreviewFormData(updatedData);
};
// Get table data safely and initialize if needed
const getTableData = (tableName) => {
if (!tableName) return [];
// Directly check the form store without using safeGetField to avoid warnings
const formData = formStore.previewFormData || {};
// If field exists and is an array, return it
if (formData.hasOwnProperty(tableName) && Array.isArray(formData[tableName])) {
return formData[tableName];
}
// If data doesn't exist, initialize it immediately (no nextTick needed for initial render)
const initialData = [];
const updatedFormData = { ...formData, [tableName]: initialData };
formStore.updatePreviewFormData(updatedFormData);
return initialData;
};
// Update table data for repeating-table component
const updateTableData = (newData) => {
const tableName = props.component.props.name;
if (!tableName) return;
const updatedFormData = { ...formStore.previewFormData, [tableName]: newData };
formStore.updatePreviewFormData(updatedFormData);
// 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 currentDataStr = JSON.stringify(currentData);
const newDataStr = JSON.stringify(safeNewData);
if (currentDataStr === newDataStr) return;
nextTick(() => {
const updatedFormData = { ...formStore.previewFormData, [tableName]: safeNewData };
formStore.updatePreviewFormData(updatedFormData);
// Emit the change for workflow page to sync with its local formData
emit('form-data-updated', updatedFormData);
});
};
// Form Section Component

View File

@ -70,9 +70,9 @@
</div>
<div v-if="paginatedData.length > 0" class="table-wrapper">
<div class="table-content" ref="tableContainer">
<div class="table-container" ref="tableContainer">
<table class="data-table">
<thead class="table-header-row">
<thead class="table-header">
<tr>
<th v-if="config.showRowNumbers" class="row-number-header">#</th>
<th
@ -106,7 +106,7 @@
:style="{ width: getColumnWidth(column) }"
>
<div class="cell-content">
<LazyCellValue
<SimpleCellValue
:value="record[column.name]"
:column="column"
:record="record"
@ -298,8 +298,8 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import { useDebounceFn } from '@vueuse/core'
// Lazy cell component for better performance
const LazyCellValue = defineComponent({
// Simple cell component that shows data immediately
const SimpleCellValue = defineComponent({
props: {
value: {
type: [String, Number, Boolean, Object],
@ -315,35 +315,7 @@ const LazyCellValue = defineComponent({
}
},
setup(props) {
const isVisible = ref(false)
const cellRef = ref(null)
const observer = ref(null)
onMounted(() => {
if (cellRef.value) {
observer.value = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
isVisible.value = true
observer.value?.disconnect()
}
})
},
{ threshold: 0.1 }
)
observer.value.observe(cellRef.value)
}
})
onUnmounted(() => {
observer.value?.disconnect()
})
const formatValue = computed(() => {
if (!isVisible.value) return ''
const value = props.value
if (value === null || value === undefined || value === '') {
return '-'
@ -370,13 +342,11 @@ const LazyCellValue = defineComponent({
})
return {
isVisible,
cellRef,
formatValue
}
},
template: `
<span ref="cellRef" class="cell-value">
<span class="cell-value">
{{ formatValue }}
</span>
`
@ -400,7 +370,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
// Reactive state
const data = ref([...props.modelValue])
const data = ref([...(props.modelValue || [])])
const searchQuery = ref('')
const showModal = ref(false)
const showDeleteConfirm = ref(false)
@ -487,7 +457,7 @@ const isAddDisabled = computed(() => {
})
const showActions = computed(() => {
return (props.config.allowEdit || props.config.allowDelete) && !props.isPreview
return props.config.allowEdit || props.config.allowDelete
})
const showRecordCount = computed(() => {
@ -543,23 +513,48 @@ const visiblePages = computed(() => {
return rangeWithDots.filter((item, index, array) => array.indexOf(item) === index)
})
// Guard to prevent recursive updates
const isUpdatingFromProps = ref(false)
// Watch for external data changes
watch(() => props.modelValue, (newValue) => {
data.value = [...newValue]
// Handle the case where newValue might be undefined/null
const safeNewValue = newValue || []
// Prevent circular updates by checking if data actually changed
const newDataStr = JSON.stringify(safeNewValue)
const currentDataStr = JSON.stringify(data.value)
if (newDataStr === currentDataStr) return
isUpdatingFromProps.value = true
data.value = [...safeNewValue]
// Clear caches when data changes
columnCache.value.clear()
recordKeys.value.clear()
}, { deep: true })
nextTick(() => {
isUpdatingFromProps.value = false
})
}, { deep: true, immediate: true })
// Watch for internal data changes and emit
watch(data, (newData) => {
emit('update:modelValue', [...newData])
// Watch for internal data changes and emit (only when not updating from props)
watch(data, (newData, oldData) => {
if (isUpdatingFromProps.value) return
// Check if data actually changed
const newDataStr = JSON.stringify(newData)
const oldDataStr = JSON.stringify(oldData)
if (newDataStr === oldDataStr) return
nextTick(() => {
emit('update:modelValue', [...newData])
})
}, { deep: true })
// Methods
const openAddModal = () => {
if (props.isPreview) return
editingIndex.value = null
formData.value = {}
@ -572,8 +567,6 @@ const openAddModal = () => {
}
const openEditModal = (record, index) => {
if (props.isPreview) return
editingIndex.value = index
formData.value = { ...record }
showModal.value = true
@ -606,8 +599,6 @@ const saveRecord = (formData) => {
}
const deleteRecord = (index) => {
if (props.isPreview) return
if (props.config.confirmDelete) {
deleteIndex.value = index
showDeleteConfirm.value = true
@ -693,18 +684,56 @@ onUnmounted(() => {
</script>
<style scoped>
/* Performance optimizations */
.table-content {
overflow-x: auto;
/* Table Container with Sticky Header */
.table-container {
overflow: hidden;
background: white;
max-height: 500px;
overflow-y: auto;
max-height: 600px;
will-change: transform;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
width: 100%;
}
.data-table {
table-layout: fixed;
width: 100%;
border-collapse: collapse;
border-spacing: 0;
margin: 0;
}
/* Sticky Header */
.table-header {
position: sticky;
top: 0;
z-index: 10;
background: #f9fafb;
border-bottom: 2px solid #e5e7eb;
}
/* Scrollable Body */
.table-body {
background: white;
}
/* Custom scrollbar for webkit browsers */
.table-container::-webkit-scrollbar {
width: 8px;
}
.table-container::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.column-header,
@ -875,7 +904,7 @@ onUnmounted(() => {
}
.table-wrapper {
@apply overflow-x-auto;
@apply overflow-hidden w-full;
}
.table-content {
@ -894,6 +923,16 @@ onUnmounted(() => {
.row-number-header,
.actions-header {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
background: #f9fafb;
border-right: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
position: relative;
vertical-align: top;
}
.column-header:last-child,
.actions-header {
border-right: none;
}
.row-number-header {
@ -932,6 +971,18 @@ onUnmounted(() => {
.data-cell,
.actions-cell {
@apply px-6 py-4 whitespace-nowrap;
border-bottom: 1px solid #f3f4f6;
border-right: 1px solid #f3f4f6;
background: white;
vertical-align: middle;
margin: 0;
padding-left: 24px;
padding-right: 24px;
}
.data-cell:last-child,
.actions-cell {
border-right: none;
}
.row-number {
@ -939,11 +990,11 @@ onUnmounted(() => {
}
.cell-content {
@apply flex items-center;
@apply flex items-center w-full;
}
.cell-value {
@apply text-sm text-gray-900;
@apply text-sm text-gray-900 w-full;
}
.action-buttons-row {

View File

@ -2579,6 +2579,10 @@ watchEffect(() => {
case 'dynamic-list':
newDefaults[component.props.name] = Array.isArray(component.props.defaultItems) ? [...component.props.defaultItems] : [];
break;
case 'repeating-table':
// Initialize repeating table with empty array or default data
newDefaults[component.props.name] = Array.isArray(component.props.defaultData) ? [...component.props.defaultData] : [];
break;
case 'select':
case 'radio':
if (Array.isArray(component.props.options) && component.props.options.length > 0) {
@ -2674,25 +2678,32 @@ const handleFormKitInput = (formData, node) => {
console.log('[FormBuilder] FormStore preview data updated to:', JSON.parse(JSON.stringify(formStore.previewFormData)));
};
// Make form data accessible to component previews (for UI rendering, not for triggering script engine)
watchEffect(() => {
if (formStore) { // Ensure formStore is available
formStore.updatePreviewFormData(previewFormData.value);
}
});
// Watch for changes in previewFormData to trigger FormScriptEngine
// Only update store if data actually changed to prevent recursion
watch(() => previewFormData.value, (newData, oldData) => {
if (!isPreview.value) return; // Only in preview mode
// Check if data actually changed to prevent unnecessary updates
const newDataStr = JSON.stringify(newData);
const oldDataStr = JSON.stringify(oldData);
if (newDataStr === oldDataStr) {
return; // No actual change, skip update
}
console.log('[FormBuilder] previewFormData watcher triggered!');
console.log('[FormBuilder] New data:', JSON.parse(JSON.stringify(newData)));
console.log('[FormBuilder] Old data:', oldData ? JSON.parse(JSON.stringify(oldData)) : 'undefined');
console.log('[FormBuilder] New data:', newDataStr);
console.log('[FormBuilder] Old data:', oldDataStr);
// Update form store
formStore.updatePreviewFormData(newData);
console.log('[FormBuilder] FormStore preview data updated to:', JSON.parse(JSON.stringify(formStore.previewFormData)));
// Use nextTick to avoid synchronous updates that can cause recursion
nextTick(() => {
// Only update store if the data is still different (hasn't been changed by something else)
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 });
const navigateToManage = () => {

View File

@ -1214,7 +1214,7 @@ watch(currentStep, async (newStep) => {
if (currentForm.value?.formComponents) {
formStore.formComponents = currentForm.value.formComponents;
// Initialize repeating groups in form data
// Initialize repeating groups and tables in form data
const updatedFormData = { ...formData.value };
currentForm.value.formComponents.forEach(component => {
if (component.type === 'repeating-group' && component.props?.name) {
@ -1240,6 +1240,13 @@ watch(currentStep, async (newStep) => {
updatedFormData[groupName] = initialGroups;
}
} else if (component.type === 'repeating-table' && component.props?.name) {
const tableName = component.props.name;
// If the field doesn't exist or is not an array, initialize it as empty array
if (!updatedFormData[tableName] || !Array.isArray(updatedFormData[tableName])) {
updatedFormData[tableName] = component.props.defaultData ? [...component.props.defaultData] : [];
}
}
});