Implementing a Data Table with Vue.js for Create, Read, Update, Delete, Copy, Revert, and Submit Operations
Table Feature Implementation using vxe-table@4.5.21
Implementation Overview
Template Structure
<vxe-table
ref="dataTableRef"
:data="displayData"
keep-source
:edit-config="{ trigger: 'dblclick', mode: 'cell', showStatus: true, enabled: isEditable }"
style="width: 100%; user-select: text"
height="100%"
class="dynamic-vxe-table"
:row-class-name="applyRowStyles"
:cell-class-name="applyCellStyles"
:row-config="{ useKey: true }"
:tooltip-config="{ enterable: true, theme: 'light' }"
v-loading="isLoading"
border
:scroll-x="{ enabled: true, gt: 10 }"
:scroll-y="{ enabled: true, gt: 100 }"
@cell-click="handleCellSelection"
>
<vxe-column
v-for="(colDef, idx) in columnDefinitions"
:key="idx"
:field="colDef.fieldName"
:title="colDef.headerText"
:show-header-overflow="true"
min-width="180"
:show-overflow="true"
:edit-render="{ enabled: !!colDef.headerText, autofocus: '.el-input__inner' }"
>
<template #edit="{ row }">
<el-input v-model="row[colDef.fieldName]" type="text" :placeholder="generatePlaceholder(row, colDef)"></el-input>
</template>
</vxe-column>
</vxe-table>
Template Attribute Configuration
keep-source: Essential for tracking edit states, temporary deletions, and data restoration.
edit-config: Configures cell editing mode activated by double-click; row mode is an alternative. showStatus combined with keep-source enables visual distinction between new rows, modified cels, and original data.
row-class-name: Applies custom CSS classes to selected rows for style modification.
cell-class-name: Applies custom CSS classes to selected cells for style modification.
row-config: useKey sets a unique identifier for each table row.
scroll-x/scroll-y: Configures virtual scrolling for handling large datasets.
Column-Specific Attributes
edit-render: autofocus ensures immediate focus on the input field when editing begins.
placeholder: Defines default hint text for editable cells.
Script Implementation
Reactive State Variables
import { VxeTableInstance, VxeTableEvents } from 'vxe-table';
const originalDataset = ref<any[]>([]); // Baseline data for revert operations
const displayData = ref<any[]>([]); // Data bound to the table
const columnDefinitions = ref<any[]>([]); // Column configuration (headers, placeholders, editability)
const updatePreviewParameters = ref<any>({}); // Parameters for preview operations
const dataTableRef = ref<VxeTableInstance>(); // Table component reference
const activeSelection = ref<any[]>([]); // Currently selected cells/rows
Computed Properties
// Determines if the table is editable
const isEditable = computed(() => {
// Logic based on API response or state
return ...;
});
// Checks if copy action is available
const isCopyActionDisabled = computed(() => {
return !activeSelection.value[0]?.row;
});
// Checks if delete action is available
const isDeleteActionDisabled = computed(() => {
return !activeSelection.value[0]?.row;
});
// Determines if revert action is available for selected items
const isRevertActionDisabled = computed(() => {
const tableInstance = dataTableRef.value;
if (tableInstance && activeSelection.value[0]) {
for (let idx = 0; idx < activeSelection.value.length; idx++) {
const selectedItem = activeSelection.value[idx];
// Check for newly inserted rows
if (!!tableInstance.isInsertByRow(selectedItem.row)) return false;
// Check for cell or row updates
if (selectedItem.field && tableInstance.isUpdateByRow(selectedItem.row, selectedItem.field)) {
return false;
}
if (!selectedItem.field && tableInstance.isUpdateByRow(selectedItem.row)) {
return false;
}
// Check for pending deletion markers
if (isMarkedForRemoval(selectedItem.row)) {
return false;
}
}
}
return true;
});
Table Interaction and Event Handlers
onMounted(() => {
window.addEventListener('dblclick', clearSelectionOnExternalClick);
});
onUnmounted(() => {
window.removeEventListener('dblclick', clearSelectionOnExternalClick);
});
// Clears selection when double-clicking outside the table
const clearSelectionOnExternalClick = (event?: any) => {
activeSelection.value = [];
};
const handleCellSelection: VxeTableEvents.CellClick = ({ row, rowIndex, column, columnIndex, $event }) => {
if (!isEditable.value) return;
// Toggle selection if clicking the same cell/row
if (activeSelection.value.length === 1) {
const firstSelected = activeSelection.value[0];
if (rowIndex === firstSelected.rowIndex && (columnIndex === firstSelected.columnIndex || (!columnIndex && !firstSelected.columnIndex))) {
activeSelection.value = [];
return;
}
}
// Define the selection target (row or cell)
const selectionTarget = { row, rowIndex };
if (columnIndex != 0) {
Object.assign(selectionTarget, { field: column.field, columnIndex });
}
// Handle modifier keys for multi-selection
if ($event.ctrlKey) {
handleCtrlMultiSelect(row, rowIndex);
return;
}
if ($event.shiftKey) {
handleShiftMultiSelect(rowIndex);
return;
}
// Default single selection
activeSelection.value = [selectionTarget];
};
const applyRowStyles = ({ rowIndex }: any) => {
const matchedRowSelection = activeSelection.value.filter((item: any) => item.rowIndex === rowIndex);
if (!matchedRowSelection[0]?.columnIndex && matchedRowSelection[0]?.rowIndex == rowIndex) {
return 'selected-row-class'; // CSS class for selected rows
}
return '';
};
const applyCellStyles = ({ rowIndex, columnIndex }: any) => {
const matchedCellSelection = activeSelection.value.filter((item: any) => item.rowIndex === rowIndex);
if (matchedCellSelection[0]?.rowIndex == rowIndex && matchedCellSelection[0]?.columnIndex == columnIndex) {
return 'selected-cell-class'; // CSS class for selected cells
}
return '';
};
const generatePlaceholder = (rowData: any, columnDef: any) => {
let hintText = '';
const tableInstance = dataTableRef.value;
if (tableInstance) {
// Logic to determine placeholder text
// ...
}
return hintText;
};
Multi-Row Selection Logic
// CTRL-click for adding/removing individual rows from selection
const handleCtrlMultiSelect = (rowData: any, rowIdx: number) => {
// Standardize selection to rows only
activeSelection.value = activeSelection.value.map((item: any) => ({
row: item.row,
rowIndex: item.rowIndex
}));
const selectedIndices = activeSelection.value.map((item: any) => item.rowIndex);
if (selectedIndices.includes(rowIdx)) {
activeSelection.value = activeSelection.value.filter((item: any) => item.rowIndex !== rowIdx);
} else {
activeSelection.value.push({ row: rowData, rowIndex: rowIdx });
activeSelection.value.sort((a: any, b: any) => a.rowIndex - b.rowIndex);
}
};
// SHIFT-click for selecting a range of rows
const handleShiftMultiSelect = (targetRowIdx: number) => {
// Standardize selection to rows only
activeSelection.value = activeSelection.value.map((item: any) => ({
row: item.row,
rowIndex: item.rowIndex
}));
const selectedIndices = activeSelection.value.map((item: any) => item.rowIndex);
const anchorIndex = selectedIndices[selectedIndices.length - 1] || targetRowIdx;
const tableInstance = dataTableRef.value;
if (tableInstance) {
activeSelection.value = [];
const allRows = tableInstance.getTableData().fullData || [];
const startIdx = Math.min(anchorIndex, targetRowIdx);
const endIdx = Math.max(anchorIndex, targetRowIdx);
for (let idx = startIdx; idx <= endIdx; idx++) {
activeSelection.value.push({ row: allRows[idx], rowIndex: idx });
}
}
};
Data Management Methods
/**
* Populates the table with data.
* @param headers Column header definitions
* @param rawData Array of data objects
*/
const loadTableData = (headers: any[], rawData: any[]) => {
const propertyMap: Record<string, string> = {};
headers.forEach((header, index) => {
propertyMap[index] = header.prop;
});
const processedData = rawData.map(dataRow => {
const mappedRow: Record<string, any> = {};
Object.keys(dataRow).forEach(key => {
const mappedKey = propertyMap[key];
if (mappedKey) {
mappedRow[mappedKey] = dataRow[key];
}
});
return mappedRow;
});
displayData.value = processedData;
originalDataset.value = JSON.parse(JSON.stringify(processedData));
};
/**
* Checks for unsaved changes (inserts, updates, pending deletions).
*/
const hasUnsavedChanges = () => {
const tableInstance = dataTableRef.value;
let modified = false;
if (tableInstance) {
const changeSet = tableInstance.getRecordset();
const { insertRecords, updateRecords, pendingRecords } = changeSet;
modified = [...insertRecords, ...updateRecords, ...pendingRecords].length > 0;
}
return modified;
};
/**
* Adds a new row.
* @param position Insertion index (null for top, -1 for bottom, specific index)
*/
const addNewRow = async (position?: number) => {
const tableInstance = dataTableRef.value;
if (tableInstance) {
const newRows = tableInstance.getInsertRecords();
const newRecord: Record<string, any> = {};
columnDefinitions.value.forEach(col => {
newRecord[col.fieldName] = null;
});
newRecord['RowID'] = displayData.value[displayData.value.length - 1]['RowID'] * 1 + newRows.length + 1;
const { row: insertedRow } = await tableInstance.insertAt(newRecord, position);
await tableInstance.setEditCell(insertedRow, columnDefinitions.value[1].fieldName);
}
};
/**
* Duplicates selected rows.
*/
const duplicateRows = async () => {
const tableInstance = dataTableRef.value;
if (tableInstance && activeSelection.value[0]?.row) {
activeSelection.value.forEach(async (selectedItem) => {
const existingNewRows = tableInstance.getInsertRecords();
const duplicateRecord = {
...selectedItem.row,
'RowID': displayData.value[displayData.value.length - 1]['RowID'] * 1 + existingNewRows.length + 1,
};
delete duplicateRecord['_X_ROW_KEY'];
const { row: newRow } = await tableInstance.insertAt(duplicateRecord, -1);
await tableInstance.setEditCell(newRow, columnDefinitions.value[1].fieldName);
});
}
};
/**
* Removes rows (soft delete for original data, hard delete for new rows).
* @param markForDeletion true to mark for deletion, false to restore
*/
const removeRows = (markForDeletion: boolean) => {
const tableInstance = dataTableRef.value;
if (tableInstance) {
activeSelection.value.forEach((selectedItem) => {
if (!!tableInstance.isInsertByRow(selectedItem.row)) {
tableInstance.remove(selectedItem.row); // Remove newly added rows
} else {
tableInstance.revertData(selectedItem.row); // Revert any edits first
tableInstance.setPendingRow(selectedItem.row, markForDeletion); // Mark/unmark for deletion
}
});
}
};
/**
* Reverts changes for selected items.
*/
const revertSelectedChanges = () => {
const tableInstance = dataTableRef.value;
if (tableInstance) {
activeSelection.value.forEach((selectedItem) => {
if (isMarkedForRemoval(selectedItem.row)) {
removeRows(false); // Restore a soft-deleted row
} else if (!!tableInstance.isInsertByRow(selectedItem.row)) {
tableInstance.remove(selectedItem.row); // Remove a newly added row
} else if (selectedItem.field) {
tableInstance.revertData(selectedItem.row, selectedItem.field); // Revert a cell
} else {
tableInstance.revertData(selectedItem.row); // Revert an entire row
}
});
}
};
/**
* Generates a preview of pending changes.
*/
const previewPendingChanges = () => {
if (!hasUnsavedChanges()) return;
const tableInstance = dataTableRef.value;
if (tableInstance) {
const changeSet = tableInstance.getRecordset();
const { insertRecords, pendingRecords, updateRecords } = changeSet;
// Process and display changes for preview
// ...
}
};
/**
* Submits all changes to the backend.
*/
const submitAllChanges = () => {
if (!hasUnsavedChanges()) return;
// Submission logic
// ...
};
Style Configuration
Reference styles. Only selection styles are mandatory; others can use defaults.
.dynamic-vxe-table {
.selected-row-class {
.vxe-body--column {
/* Selected row styling */
box-shadow: inset 0 -0.5px 0 2px #007bff;
user-select: none;
}
}
.selected-cell-class {
/* Selected cell styling */
box-shadow: inset 0 0 0 2px #007bff;
}
.col--dirty {
background-color: /* color for edited cells */;
&::before {
display: none;
}
}
.row--new {
background-color: /* color for new/copied rows */;
.vxe-body--column {
&::before {
display: none;
}
}
}
.row--pending {
background-color: /* color for rows marked for deletion */;
color: inherit;
text-decoration: none;
cursor: default;
.vxe-body--column::after {
display: none;
}
}
}