Enhance RepeatingTable Component with Performance Optimizations

- Added a performance warning for tables with more than 20 columns, advising users to reduce column count for better performance.
- Implemented lazy loading for cell values to improve rendering efficiency in large datasets.
- Introduced column virtualization to limit visible columns to the first 20 for performance enhancement.
- Updated pagination controls and improved the overall structure of the table for better user experience.
- Enhanced styling for performance warnings and loading states, ensuring a more responsive interface.
This commit is contained in:
Afiq 2025-08-06 13:38:11 +08:00
parent edbfa72787
commit a2b707eb6e

View File

@ -55,16 +55,31 @@
<!-- Data Table -->
<div class="table-container">
<!-- Performance warning for too many columns -->
<div v-if="config.columns.length > 20" 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.
</p>
</div>
</div>
</div>
<div v-if="paginatedData.length > 0" class="table-wrapper">
<div class="table-content">
<div class="table-content" ref="tableContainer">
<table class="data-table">
<thead class="table-header-row">
<tr>
<th v-if="config.showRowNumbers" class="row-number-header">#</th>
<th
v-for="column in config.columns"
v-for="column in visibleColumns"
:key="column.name"
class="column-header"
:style="{ width: getColumnWidth(column) }"
>
<div class="column-header-content">
<span class="header-text">{{ column.label }}</span>
@ -77,7 +92,7 @@
<tbody class="table-body">
<tr
v-for="(record, index) in paginatedData"
:key="index"
:key="getRecordKey(record, index)"
class="data-row"
:class="{ 'row-hover': !isPreview }"
>
@ -85,12 +100,17 @@
<div class="row-number">{{ getRowNumber(index) }}</div>
</td>
<td
v-for="column in config.columns"
v-for="column in visibleColumns"
:key="column.name"
class="data-cell"
:style="{ width: getColumnWidth(column) }"
>
<div class="cell-content">
<span class="cell-value">{{ formatCellValue(record[column.name], column) }}</span>
<LazyCellValue
:value="record[column.name]"
:column="column"
:record="record"
/>
</div>
</td>
<td v-if="showActions" class="actions-cell">
@ -124,20 +144,16 @@
<!-- Empty State -->
<div v-else class="empty-state">
<div class="empty-content">
<div class="empty-icon-container">
<Icon name="heroicons:table-cells" class="empty-icon" />
</div>
<div class="empty-text">
<h4 class="empty-title">No records yet</h4>
<p class="empty-description">
{{ searchQuery ? 'No records match your search criteria.' : 'Click "' + (config.buttonText || 'Add Record') + '" to add your first record.' }}
</p>
</div>
<Icon name="heroicons:table-cells" class="empty-icon" />
<h3 class="empty-title">No records yet</h3>
<p class="empty-message">
{{ config.help || 'Add your first record to get started.' }}
</p>
<RsButton
v-if="!searchQuery && !isAddDisabled"
v-if="!isAddDisabled"
@click="openAddModal"
variant="primary"
size="md"
size="sm"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
{{ config.buttonText || 'Add Record' }}
@ -147,92 +163,56 @@
</div>
<!-- Pagination -->
<div v-if="filteredData.length > 0 && totalPages > 1" class="pagination-container">
<div v-if="totalPages > 1" class="pagination-container">
<div class="pagination-info">
<span class="pagination-text">
Showing {{ startRecord }} to {{ endRecord }} of {{ filteredData.length }} records
</span>
Showing {{ startRecord }} to {{ endRecord }} of {{ filteredData.length }} records
</div>
<div class="pagination-controls">
<div class="page-size-selector">
<label class="page-size-label">Show:</label>
<select v-model="pageSize" @change="currentPage = 1" class="page-size-select">
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<span class="page-size-suffix">per page</span>
<RsButton
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
Previous
</RsButton>
<div class="page-numbers">
<RsButton
v-for="page in visiblePages"
:key="page"
@click="page === '...' ? null : goToPage(page)"
:variant="page === currentPage ? 'primary' : 'secondary-outline'"
size="sm"
:disabled="page === '...'"
class="page-button"
>
{{ page }}
</RsButton>
</div>
<div class="page-navigation">
<RsButton
@click="goToPage(1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
title="First page"
>
<Icon name="heroicons:chevron-double-left" class="w-4 h-4" />
</RsButton>
<RsButton
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
title="Previous page"
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
</RsButton>
<div class="page-numbers">
<RsButton
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:variant="page === currentPage ? 'primary' : 'secondary-outline'"
size="sm"
>
{{ page }}
</RsButton>
</div>
<RsButton
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
title="Next page"
>
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
</RsButton>
<RsButton
@click="goToPage(totalPages)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
title="Last page"
>
<Icon name="heroicons:chevron-double-right" class="w-4 h-4" />
</RsButton>
</div>
<RsButton
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
>
Next
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
</RsButton>
</div>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-container">
<div v-if="showModal" class="modal-overlay" @click="closeModal">
<div class="modal-container" @click.stop>
<div class="modal-header">
<div class="modal-title-section">
<h3 class="modal-title">
{{ editingIndex !== null ? 'Edit Record' : 'Add New Record' }}
</h3>
</div>
<h3 class="modal-title">
{{ editingIndex !== null ? 'Edit Record' : 'Add Record' }}
</h3>
<RsButton @click="closeModal" variant="secondary-text" size="sm">
<Icon name="heroicons:x-mark" class="w-6 h-6" />
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</RsButton>
</div>
@ -280,23 +260,19 @@
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="cancelDelete">
<div class="modal-container modal-small">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="cancelDelete">
<div class="modal-container delete-modal" @click.stop>
<div class="modal-header">
<div class="modal-title-section">
<h3 class="modal-title">Confirm Delete</h3>
</div>
<h3 class="modal-title">Confirm Delete</h3>
<RsButton @click="cancelDelete" variant="secondary-text" size="sm">
<Icon name="heroicons:x-mark" class="w-6 h-6" />
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</RsButton>
</div>
<div class="modal-body">
<div class="delete-warning">
<div class="warning-icon-container">
<Icon name="heroicons:exclamation-triangle" class="warning-icon" />
</div>
<div class="warning-content">
<div class="warning-content">
<Icon name="heroicons:exclamation-triangle" class="warning-icon" />
<div class="warning-text">
<p class="warning-title">Are you sure you want to delete this record?</p>
<p class="warning-message">This action cannot be undone.</p>
</div>
@ -320,6 +296,91 @@
<script setup>
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({
props: {
value: {
type: [String, Number, Boolean, Object],
default: null
},
column: {
type: Object,
required: true
},
record: {
type: Object,
required: true
}
},
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 '-'
}
if (props.column.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (props.column.type === 'time') {
return value
}
if (props.column.type === 'checkbox') {
return value ? 'Yes' : 'No'
}
if (props.column.type === 'select' && props.column.options) {
const option = props.column.options.find(opt => opt.value === value)
return option ? option.label : value
}
return value.toString()
})
return {
isVisible,
cellRef,
formatValue
}
},
template: `
<span ref="cellRef" class="cell-value">
{{ formatValue }}
</span>
`
})
const props = defineProps({
config: {
@ -347,12 +408,28 @@ const editingIndex = ref(null)
const deleteIndex = ref(null)
const formData = ref({})
const formId = ref(`table-form-${uuidv4()}`)
const tableContainer = ref(null)
// Pagination state
const currentPage = ref(1)
const pageSize = ref(10)
// Computed properties
// Performance optimizations
const columnCache = ref(new Map())
const recordKeys = ref(new Map())
// Debounced search for better performance
const debouncedSearch = useDebounceFn((query) => {
// Clear cache when search changes
columnCache.value.clear()
}, 300)
// Watch search query with debouncing
watch(searchQuery, (newQuery) => {
debouncedSearch(newQuery)
})
// Computed properties with memoization
const filteredData = computed(() => {
if (!searchQuery.value) return data.value
@ -365,6 +442,46 @@ const filteredData = computed(() => {
})
})
// Column virtualization for large datasets
const visibleColumns = computed(() => {
const columns = props.config.columns
const maxColumns = 20 // Limit visible columns for performance
if (columns.length <= maxColumns) {
return columns
}
// Return first 20 columns for performance
return columns.slice(0, maxColumns)
})
// Column width calculation
const getColumnWidth = (column) => {
const baseWidth = 150
const typeWidths = {
text: 150,
number: 100,
email: 200,
tel: 120,
date: 120,
time: 100,
url: 200,
select: 150,
checkbox: 80,
textarea: 200
}
return `${typeWidths[column.type] || baseWidth}px`
}
// Record key generation for better Vue rendering
const getRecordKey = (record, index) => {
if (!recordKeys.value.has(record)) {
recordKeys.value.set(record, `record-${index}-${Date.now()}`)
}
return recordKeys.value.get(record)
}
const isAddDisabled = computed(() => {
return props.config.maxRecords && data.value.length >= props.config.maxRecords
})
@ -429,6 +546,9 @@ const visiblePages = computed(() => {
// Watch for external data changes
watch(() => props.modelValue, (newValue) => {
data.value = [...newValue]
// Clear caches when data changes
columnCache.value.clear()
recordKeys.value.clear()
}, { deep: true })
// Watch for internal data changes and emit
@ -512,29 +632,8 @@ const getOriginalIndex = (record) => {
return data.value.findIndex(item => item === record)
}
const formatCellValue = (value, column) => {
if (value === null || value === undefined || value === '') {
return '-'
}
if (column.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (column.type === 'time') {
return value
}
if (column.type === 'checkbox') {
return value ? 'Yes' : 'No'
}
if (column.type === 'select' && column.options) {
const option = column.options.find(opt => opt.value === value)
return option ? option.label : value
}
return value.toString()
const getRowNumber = (index) => {
return startRecord.value + index
}
const getFormKitType = (columnType) => {
@ -586,17 +685,115 @@ const goToPage = (page) => {
currentPage.value = page
}
const getRowNumber = (index) => {
return (currentPage.value - 1) * pageSize.value + index + 1
}
// Watch for search changes to reset pagination
watch(searchQuery, () => {
currentPage.value = 1
// Cleanup on unmount
onUnmounted(() => {
columnCache.value.clear()
recordKeys.value.clear()
})
</script>
<style scoped>
/* Performance optimizations */
.table-content {
overflow-x: auto;
overflow-y: auto;
max-height: 600px;
will-change: transform;
}
.data-table {
table-layout: fixed;
width: 100%;
border-collapse: collapse;
}
.column-header,
.data-cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
}
.cell-content {
overflow: hidden;
text-overflow: ellipsis;
}
.cell-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Virtual scrolling optimizations */
.data-row {
contain: layout style paint;
}
/* Lazy loading optimizations */
.cell-value:empty {
min-height: 1.2em;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Performance warning styles */
.performance-warning {
@apply bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4;
}
.warning-content {
@apply flex items-start space-x-3;
}
.warning-icon {
@apply w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0;
}
.warning-text {
@apply flex-1;
}
.warning-title {
@apply text-sm font-medium text-yellow-800 mb-1;
}
.warning-message {
@apply text-xs text-yellow-700;
}
/* Responsive optimizations */
@media (max-width: 768px) {
.table-content {
max-height: 400px;
}
.column-header,
.data-cell {
min-width: 80px;
max-width: 120px;
}
}
@media (max-width: 480px) {
.table-content {
max-height: 300px;
}
.column-header,
.data-cell {
min-width: 60px;
max-width: 100px;
}
}
.repeating-table-container {
@apply border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm w-full;
min-width: 0;