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:
parent
edbfa72787
commit
a2b707eb6e
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user