table-forge
Version:
A flexible, interactive data table generator with built-in sorting, filtering, and inline editing capabilities
1,194 lines (1,032 loc) • 51.7 kB
JavaScript
class TableBuilder {
/**
* Creates a new TableCreator instance.
*
* Provides a flexible, interactive data table with features like sorting, filtering, and inline editing.
*
* @param {string} containerId - DOM element ID where the table will be rendered
* @param {Array<Object>} data - Array of objects representing table rows. Inside each object, keys represent column names and values represent cell data.
* @param {Object} options - Configuration options for table behavior and appearance
*/
constructor(containerId, data, options = {}) {
this.container = document.getElementById(containerId);
this.data = data;
this.originalData = [...data]; // Keep original data for filtering
this.options = {
headerBgColor: options.headerBgColor || '#9ca3af', // Header background color
headerTextColor: options.headerTextColor || '#ffffff', // Header text color
headerFilteredBgColor: options.headerFilteredBgColor || '#60a5fa', // Filtered header background color
bodyBgColor: options.bodyBgColor || '#ffffff', // Default white
bodyTextColor: options.bodyTextColor || '#000000', // Default black
columnOrder: options.columnOrder || [], // Column ordering. Define the order of columns as an array of strings. If empty, columns will be displayed in the order they appear in the data.
columnDisplayNames: options.columnDisplayNames || {}, // Key-value pairs where key is the column name and value is the name to display in the header
inputColumns: options.inputColumns || [], // Columns that will have input fields for editing
dropdownColumns: options.dropdownColumns || {}, // Key-value pairs where key is the column name and value is an array of options for dropdowns
requiredColumns: options.requiredColumns || [], // Columns that are required and will have a red asterisk in the header
frozenColumns: options.frozenColumns || [], // Columns that will be frozen when scrolling horizontally
calculatedColumns: options.calculatedColumns || {}, // Key-value pairs where key is the column name and value is a function that takes the row data and returns the calculated value
enableSorting: options.enableSorting || false, // Enable sorting functionality
enableFiltering: options.enableFiltering || false, // Enable filtering functionality
enableAddRow: options.enableAddRow || false, // Enable "Add New Row" button
enableDeleteRow: options.enableDeleteRow || false, // Enable delete row functionality
enableSaveConfig: options.enableSaveConfig || false, // Enable save configuration button
enableExcelExport: options.enableExcelExport || false, // Enable Excel export functionality. Add the SheetJS library for Excel file creation in your HTML template --> <script src="https://cdn.jsdelivr.net/npm/xlsx/dist/xlsx.full.min.js"></script>
enableJsonExport: options.enableJsonExport || false, // Enable JSON export functionality
saveEndpoint: options.saveEndpoint || null, // Endpoint to send data to when saving configuration.
saveDataFormatter: options.saveDataFormatter || (data => data), // Function to format data before sending to the save endpoint. Default is identity function.
excelFileName: options.excelFileName || 'table_export.xlsx', // Default Excel file name
jsonFileName: options.jsonFileName || 'table_export.json', // Default JSON file name
};
this.sortConfig = {
column: null,
direction: 'asc'
};
this.filters = {}; // Store active filters
this.hasUnsavedChanges = false;
// Add calculated columns after all initialization
this.addCalculatedColumns();
// Store the string representation after calculated columns are added
this.originalDataString = JSON.stringify(data);
console.log('TableCreator initialized with options:', this.options);
console.log('TableCreator initialized with data:', this.data);
}
/**
* Renders the table with all its components.
*
* Purpose: Creates the complete table structure including headers, body, and control buttons.
* The table is wrapped in containers that enable scrolling and proper layout management.
* This is the main method that brings all the pieces together.
*/
create() {
// Create outer wrapper for scroll handling
const outerWrapper = document.createElement('div');
outerWrapper.className = 'relative bg-white rounded-lg shadow-md border border-gray-200 overflow-hidden';
// Create table wrapper for scrolling
const wrapper = document.createElement('div');
wrapper.className = 'table-wrapper overflow-auto max-h-[70vh]';
// Add CSS for frozen columns
this.addFrozenColumnsStyles();
const table = document.createElement('table');
table.className = 'border-collapse table-auto relative w-full';
// Create header and body
const thead = this.createHeader();
const tbody = this.createBody();
table.appendChild(thead);
table.appendChild(tbody);
wrapper.appendChild(table);
outerWrapper.appendChild(wrapper);
// Only create buttons container if there are buttons to show
if (this.options.enableSaveConfig || this.options.enableAddRow ||
this.options.enableExcelExport || this.options.enableJsonExport) {
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'sticky bottom-0 left-0 w-full bg-white border-t py-2 px-4 flex justify-between items-center z-10';
const leftButtons = document.createElement('div');
leftButtons.className = 'flex gap-2';
const rightButtons = document.createElement('div');
rightButtons.className = 'flex gap-2';
// Add JSON export button if enabled
if (this.options.enableJsonExport) {
const jsonExportButton = document.createElement('button');
jsonExportButton.type = 'button';
jsonExportButton.textContent = '📄 Export to JSON';
jsonExportButton.className = 'bg-yellow-600 hover:bg-yellow-700 text-white font-semibold py-2 px-4 rounded';
jsonExportButton.addEventListener('click', (e) => {
e.preventDefault();
this.exportToJson();
});
leftButtons.appendChild(jsonExportButton);
}
// Add Excel export button if enabled
if (this.options.enableExcelExport) {
const exportButton = document.createElement('button');
exportButton.type = 'button'; // Use type="button" to prevent form submission
exportButton.textContent = '📊 Export to Excel';
exportButton.className = 'bg-green-700 hover:bg-green-800 text-white font-semibold py-2 px-4 rounded';
exportButton.addEventListener('click', (e) => {
e.preventDefault(); // Prevent default button behavior
this.exportToExcel();
});
leftButtons.appendChild(exportButton);
}
// Add save button if enabled
if (this.options.enableSaveConfig) {
const saveButton = document.createElement('button');
saveButton.type = 'button';
saveButton.textContent = 'Save Configuration';
saveButton.className = 'bg-green-500 text-white font-semibold py-2 px-4 rounded opacity-50 cursor-not-allowed';
saveButton.disabled = true;
saveButton.id = 'saveConfigBtn';
saveButton.title = 'No changes to save'; // Default tooltip
// Add tooltip container for custom styling
const tooltipContainer = document.createElement('div');
tooltipContainer.className = 'relative inline-block';
// Add hover tooltip
const tooltip = document.createElement('div');
tooltip.className = 'absolute hidden bg-gray-800 text-white text-sm rounded px-2 py-1 bottom-full left-1/2 transform -translate-x-1/2 mb-2 whitespace-nowrap';
tooltipContainer.appendChild(tooltip);
// Add event listeners for tooltip
saveButton.addEventListener('mouseenter', () => {
if (this.hasActiveFilters()) {
tooltip.textContent = 'Cannot save while filters are active. Clear filters first.';
tooltip.classList.remove('hidden');
} else if (!this.hasUnsavedChanges) {
tooltip.textContent = 'No changes to save';
tooltip.classList.remove('hidden');
}
});
saveButton.addEventListener('mouseleave', () => {
tooltip.classList.add('hidden');
});
saveButton.addEventListener('click', async () => {
saveButton.disabled = true;
saveButton.textContent = 'Saving...';
const success = await this.saveConfiguration();
if (success) {
saveButton.className = 'bg-green-500 text-white font-semibold py-2 px-4 rounded opacity-50 cursor-not-allowed';
saveButton.textContent = 'Save Configuration';
saveButton.disabled = true;
tooltip.textContent = 'Changes saved successfully';
} else {
saveButton.className = 'bg-green-500 hover:bg-green-600 text-white font-semibold py-2 px-4 rounded';
saveButton.textContent = 'Save Configuration';
saveButton.disabled = false;
tooltip.textContent = 'Error saving changes. Try again.';
}
});
tooltipContainer.appendChild(saveButton);
rightButtons.appendChild(tooltipContainer);
}
// Add "Add New Row" button if enabled
if (this.options.enableAddRow) {
const addButton = document.createElement('button');
addButton.textContent = '+ Add New Row';
addButton.className = 'bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded';
addButton.addEventListener('click', () => {
this.addNewRow();
this.checkForChanges();
});
rightButtons.appendChild(addButton);
}
buttonsContainer.appendChild(leftButtons);
buttonsContainer.appendChild(rightButtons);
wrapper.appendChild(buttonsContainer);
}
this.container.appendChild(outerWrapper);
// Add copy functionality
this.addCopyHandler(table);
}
/**
* Creates the table header section.
*
* Purpose: Generates interactive column headers with:
* - Sorting functionality
* - Filtering capability
* - Visual indicators for required fields
* - Support for frozen columns
* - Consistent styling and borders
*
* @returns {HTMLElement} thead element with all header functionality
*/
createHeader() {
const thead = document.createElement('thead');
const tr = document.createElement('tr');
if (this.data.length === 0) return thead;
const orderedColumns = this.getOrderedColumns();
orderedColumns.forEach(key => {
const th = document.createElement('th');
// Updated header styling
th.className = `sticky top-0 px-6 py-3 text-sm font-semibold tracking-wider relative`;
// Apply background color based on filter state
th.style.backgroundColor = this.filters[key]
? this.options.headerFilteredBgColor
: this.options.headerBgColor;
th.style.color = this.options.headerTextColor;
// Create border element inside th
const borderBottom = document.createElement('div');
borderBottom.className = 'absolute bottom-0 left-0 right-0';
borderBottom.style.height = '3px'; // Thicker border
borderBottom.style.backgroundColor = '#d1d5db';
borderBottom.style.boxShadow = '0 1px 1px rgba(0,0,0,0.1)';
th.appendChild(borderBottom);
if (this.options.frozenColumns.includes(key)) {
th.setAttribute('data-frozen-column', key);
}
// Updated header content wrapper
const headerContent = document.createElement('div');
headerContent.className = this.options.enableSorting || this.options.enableFiltering
? 'flex items-center justify-between space-x-4'
: 'flex items-center justify-center'; // Center alignment when no buttons
// Updated column name styling
const columnName = document.createElement('span');
columnName.className = 'flex items-center whitespace-nowrap';
columnName.innerHTML = `
<span class="font-medium">
${this.options.columnDisplayNames[key] || key}
</span>
${this.options.requiredColumns.includes(key)
? '<span class="text-red-500 ml-1">*</span>'
: ''}
`;
columnName.title = key;
headerContent.appendChild(columnName);
// Updated buttons container - only create if needed
if (this.options.enableSorting || this.options.enableFiltering) {
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'flex items-center space-x-2';
// Only add sort button if enabled
if (this.options.enableSorting) {
const sortBtn = document.createElement('button');
sortBtn.innerHTML = '↕️';
sortBtn.className = 'hover:bg-opacity-20 hover:bg-white rounded p-1 transition-colors';
sortBtn.title = `Sort by ${this.options.columnDisplayNames[key] || key}`;
sortBtn.addEventListener('click', () => this.handleSort(key));
buttonsContainer.appendChild(sortBtn);
}
// Only add filter button if enabled
if (this.options.enableFiltering) {
const filterBtn = document.createElement('button');
filterBtn.type = 'button';
filterBtn.innerHTML = this.filters[key] ? '🎛️' : '🔍';
filterBtn.className = `hover:bg-opacity-20 hover:bg-white rounded p-1 transition-colors
${this.filters[key] ? 'bg-opacity-20 bg-white' : ''}`;
filterBtn.title = `Filter ${this.options.columnDisplayNames[key] || key}`;
filterBtn.addEventListener('click', (e) => {
e.preventDefault();
this.showFilterDropdown(e, key);
});
buttonsContainer.appendChild(filterBtn);
}
headerContent.appendChild(buttonsContainer);
}
th.appendChild(headerContent);
tr.appendChild(th);
});
// Update delete column header styling if enabled
if (this.options.enableDeleteRow) {
const th = document.createElement('th');
th.className = 'sticky top-0 px-6 py-3 text-sm font-semibold';
th.style.backgroundColor = this.options.headerBgColor;
th.style.color = this.options.headerTextColor;
th.innerHTML = '';
// Add border to delete column header too
const borderBottom = document.createElement('div');
borderBottom.className = 'absolute bottom-0 left-0 right-0';
borderBottom.style.height = '3px';
borderBottom.style.backgroundColor = '#d1d5db';
borderBottom.style.boxShadow = '0 1px 1px rgba(0,0,0,0.1)';
th.appendChild(borderBottom);
tr.appendChild(th);
}
thead.appendChild(tr);
return thead;
}
/**
* Creates the table body section.
*
* Purpose: Renders table data with:
* - Input fields for editable cells
* - Dropdowns for predefined options
* - Calculated columns
* - Delete row buttons
* - Proper event handling for data changes
*
* @returns {HTMLElement} tbody element with all data and interactive elements
*/
createBody() {
const tbody = document.createElement('tbody');
const orderedColumns = this.getOrderedColumns();
// Add change detection to input and select elements
const addChangeDetection = (element) => {
element.addEventListener('change', () => {
this.checkForChanges();
});
};
this.data.forEach((row, rowIndex) => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50';
// First add all data cells
orderedColumns.forEach(key => {
const value = row[key];
const td = document.createElement('td');
td.className = 'border px-4 py-2 text-xs text-center';
// Apply body colors
td.style.backgroundColor = this.options.bodyBgColor;
td.style.color = this.options.bodyTextColor;
// Add frozen column attribute if needed
if (this.options.frozenColumns.includes(key)) {
td.setAttribute('data-frozen-column', key);
}
if (this.options.dropdownColumns[key]) {
// Create dropdown
const select = document.createElement('select');
select.className = `p-1 border rounded w-full text-center ${
this.options.requiredColumns.includes(key) ? 'required' : ''
}`;
// Apply colors to dropdowns
select.style.backgroundColor = this.options.bodyBgColor;
select.style.color = this.options.bodyTextColor;
// Add required attribute if needed
if (this.options.requiredColumns.includes(key)) {
select.required = true;
}
// Remove the fixed width styles and use full width
select.style.width = '100%';
// Center the text in options
const optionsList = [...this.options.dropdownColumns[key]];
if (!optionsList.includes(value)) {
optionsList.push(value);
}
optionsList.forEach(option => {
const optionElement = document.createElement('option');
optionElement.value = option;
optionElement.textContent = option;
optionElement.style.textAlign = 'center'; // Center text in options
if (option === value) {
optionElement.selected = true;
}
select.appendChild(optionElement);
});
select.addEventListener('change', (e) => {
this.data[rowIndex][key] = String(e.target.value);
this.checkForChanges();
});
td.appendChild(select);
} else if (this.options.inputColumns.includes(key)) {
// Regular input field
const input = document.createElement('input');
input.type = 'text';
input.value = String(value || ''); // Convert to string
input.className = `p-1 border rounded text-center ${
this.options.requiredColumns.includes(key) ? 'required' : ''
}`;
// Apply colors to inputs
input.style.backgroundColor = this.options.bodyBgColor;
input.style.color = this.options.bodyTextColor;
// Add required attribute if needed
if (this.options.requiredColumns.includes(key)) {
input.required = true;
}
input.style.minWidth = 'max-content'; // optional fallback
input.style.width = '100%'; // allow content-based sizing
input.addEventListener('change', (e) => {
// Convert value to string before storing
this.data[rowIndex][key] = String(e.target.value);
this.checkForChanges();
});
td.appendChild(input);
} else {
td.textContent = value;
}
tr.appendChild(td);
});
// Add delete button cell at the end if enabled
if (this.options.enableDeleteRow) {
const deleteCell = document.createElement('td');
deleteCell.className = 'border px-4 py-2 text-xs bg-gray-50'; // Light gray background
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.innerHTML = '⛌';
deleteButton.className = 'text-red-500 hover:text-red-700 focus:outline-none';
deleteButton.title = 'Delete row';
deleteButton.addEventListener('click', (e) => {
e.preventDefault();
this.deleteRow(rowIndex);
});
deleteCell.appendChild(deleteButton);
tr.appendChild(deleteCell);
}
tbody.appendChild(tr);
});
return tbody;
}
/**
* Determines the column display order.
*
* Purpose: Manages column organization by:
* - Following user-defined column order in `this.options.columnOrder`
* - Including all columns even if not in order
* - Maintaining consistent layout
* - Supporting dynamic column addition
*
* @returns {Array<string>} Ordered array of column identifiers
*/
getOrderedColumns() {
if (!this.data.length) return [];
const allColumns = Object.keys(this.data[0]);
if (!this.options.columnOrder) {
return allColumns; // Return default order if no custom order specified
}
// Start with specified columns
const orderedColumns = [...this.options.columnOrder];
// Add any remaining columns that weren't specified in the order
allColumns.forEach(column => {
if (!orderedColumns.includes(column)) {
orderedColumns.push(column);
}
});
return orderedColumns;
}
/**
* Manages frozen column styling.
*
* Purpose: Improves table usability by:
* - Keeping important columns visible
* - Maintaining proper z-index stacking
* - Adding visual separation
* - Supporting horizontal scrolling
*/
addFrozenColumnsStyles() {
if (!this.options.frozenColumns?.length) return;
const style = document.createElement('style');
let leftOffset = 0;
const styles = this.options.frozenColumns.map((column, index) => {
const selector = `[data-frozen-column="${column}"]`;
const rule = `
${selector} {
position: sticky;
left: ${leftOffset}px;
z-index: 3;
background-color: ${this.options.bodyBgColor};
color: ${this.options.bodyTextColor};
}
${selector}:after {
content: '';
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 2px;
background-color: #d1d5db;
box-shadow: 2px 0 4px rgba(0,0,0,0.05);
}
thead ${selector} {
z-index: 4;
background-color: ${this.options.headerBgColor} !important;
color: ${this.options.headerTextColor} !important;
}
thead ${selector}:after {
background-color: #d1d5db;
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
}
.table-wrapper {
position: relative;
z-index: 1;
}
`;
leftOffset += 200;
return rule;
}).join('\n');
style.textContent = styles;
document.head.appendChild(style);
}
/**
* Processes and adds calculated columns to the table.
*
* These columns are defined in `this.options.calculatedColumns` and are computed based on other columns. `this.options.calculatedColumns` are key-value pairs where key is the column name and value is a function that takes the row data and returns the calculated value.
*
* Purpose: Manages derived data by:
* - Computing values based on other columns
* - Maintaining consistency across data copies
* - Supporting dynamic updates
* - Preserving calculations during filtering
*/
addCalculatedColumns() {
if (!this.options.calculatedColumns || Object.keys(this.options.calculatedColumns).length === 0) {
return;
}
this.data = this.data.map(row => {
const newRow = { ...row };
Object.entries(this.options.calculatedColumns).forEach(([columnName, calculator]) => {
newRow[columnName] = calculator(row);
});
return newRow;
});
// Also add to originalData for filtering
this.originalData = this.originalData.map(row => {
const newRow = { ...row };
Object.entries(this.options.calculatedColumns).forEach(([columnName, calculator]) => {
newRow[columnName] = calculator(row);
});
return newRow;
});
}
/**
* Manages column sorting functionality.
*
* Purpose: Enables data organization by:
* - Toggling sort direction on repeated clicks
* - Handling different data types appropriately
* - Maintaining visual feedback
* - Preserving table functionality after sort
*
* @param {string} column - Column identifier to sort by
*/
handleSort(column) {
if (this.sortConfig.column === column) {
// Toggle direction if same column
this.sortConfig.direction = this.sortConfig.direction === 'asc' ? 'desc' : 'asc';
} else {
// New column, default to ascending
this.sortConfig.column = column;
this.sortConfig.direction = 'asc';
}
this.data.sort((a, b) => {
const aVal = a[column];
const bVal = b[column];
// Handle different data types
if (!isNaN(aVal) && !isNaN(bVal)) {
return this.sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
}
return this.sortConfig.direction === 'asc'
? String(aVal).localeCompare(String(bVal))
: String(bVal).localeCompare(String(aVal));
});
// Refresh table
this.container.innerHTML = '';
this.create();
}
/**
* Handles data filtering.
*
* Purpose: Allows users to filter table data while:
* - Maintaining data integrity
* - Providing visual feedback
* - Supporting multiple active filters
* - Preventing save operations during filtering
*
* @param {Event} event - The triggering event
* @param {string} column - Column identifier to filter on
*/
showFilterDropdown(event, column) {
event.stopPropagation();
// Remove any existing filter dropdown
const existingDropdown = document.getElementById('filter-dropdown');
if (existingDropdown) {
existingDropdown.remove();
}
// For unfiltered columns, get values from currently filtered data
// For filtered columns, get values from original data
let availableValues;
if (this.filters[column]) {
// If this column is already filtered, show all original values
availableValues = [...new Set(this.originalData.map(row => row[column]))];
} else {
// For unfiltered columns, only show values that exist in currently filtered data
availableValues = [...new Set(this.data.map(row => row[column]))];
}
// Create dropdown container
const dropdown = document.createElement('div');
dropdown.id = 'filter-dropdown';
dropdown.className = 'absolute bg-white border rounded shadow-lg z-50 max-h-64 overflow-y-auto';
// Position dropdown below filter button
const rect = event.target.getBoundingClientRect();
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
dropdown.style.left = `${rect.left + window.scrollX}px`;
// Add search input
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search...';
searchInput.className = 'w-full p-2 border-b';
// Add "Select All" checkbox
const selectAllLabel = document.createElement('label');
selectAllLabel.className = 'block p-2 hover:bg-gray-100';
const selectAllCheckbox = document.createElement('input');
selectAllCheckbox.type = 'checkbox';
// Changed to false by default
selectAllCheckbox.checked = false;
selectAllCheckbox.className = 'mr-2';
selectAllLabel.appendChild(selectAllCheckbox);
selectAllLabel.appendChild(document.createTextNode('Select All'));
dropdown.appendChild(searchInput);
dropdown.appendChild(selectAllLabel);
// Create checkbox for each unique value
const checkboxContainer = document.createElement('div');
const createCheckboxes = (values) => {
checkboxContainer.innerHTML = '';
values.forEach(value => {
const label = document.createElement('label');
label.className = 'block p-2 hover:bg-gray-100';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = value;
checkbox.className = 'mr-2';
// Set checked based on current filters
checkbox.checked = this.filters[column] ? this.filters[column].includes(value) : true;
label.appendChild(checkbox);
label.appendChild(document.createTextNode(value || '(Blank)'));
checkboxContainer.appendChild(label);
});
};
createCheckboxes(availableValues);
dropdown.appendChild(checkboxContainer);
// Add apply and clear buttons
const buttonContainer = document.createElement('div');
buttonContainer.className = 'flex justify-between p-2 border-t';
const applyBtn = document.createElement('button');
applyBtn.textContent = 'Apply';
applyBtn.className = 'px-3 py-1 bg-blue-500 text-white rounded';
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear';
clearBtn.className = 'px-3 py-1 bg-gray-300 rounded';
buttonContainer.appendChild(clearBtn);
buttonContainer.appendChild(applyBtn);
dropdown.appendChild(buttonContainer);
// Event listeners
searchInput.addEventListener('input', (e) => {
const searchValue = e.target.value.toLowerCase();
const filteredValues = availableValues.filter(value =>
String(value).toLowerCase().includes(searchValue)
);
createCheckboxes(filteredValues);
});
selectAllCheckbox.addEventListener('change', (e) => {
const checkboxes = checkboxContainer.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = e.target.checked);
});
applyBtn.addEventListener('click', () => {
const selectedValues = Array.from(checkboxContainer.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => cb.value);
if (selectedValues.length === availableValues.length) {
delete this.filters[column];
} else {
this.filters[column] = selectedValues;
}
this.applyFilters();
// Update the filter button icon
const filterBtn = event.target;
filterBtn.innerHTML = this.filters[column] ? '🎛️' : '🔍';
filterBtn.className = `ml-2 focus:outline-none ${this.filters[column] ? 'text-blue-500' : ''}`;
dropdown.remove();
});
clearBtn.addEventListener('click', () => {
delete this.filters[column];
this.applyFilters();
// Update the filter button icon
const filterBtn = event.target;
filterBtn.innerHTML = '🔍';
filterBtn.className = 'ml-2 focus:outline-none';
dropdown.remove();
});
// Close dropdown when clicking outside
document.addEventListener('click', function closeDropdown(e) {
if (!dropdown.contains(e.target)) {
dropdown.remove();
document.removeEventListener('click', closeDropdown);
}
});
document.body.appendChild(dropdown);
}
/**
* Applies active filters to the table data.
*
* Purpose: Maintains data view consistency by:
* - Filtering data based on all active column filters
* - Updating the UI to reflect filtered state
* - Preventing save operations while filtered
* - Refreshing table with filtered data
*/
applyFilters() {
this.data = this.originalData.filter(row => {
return Object.entries(this.filters).every(([column, allowedValues]) => {
return allowedValues.includes(row[column]);
});
});
// Update save button state
this.checkForChanges();
// Refresh table
this.container.innerHTML = '';
this.create();
}
/**
* Checks for active filters.
*
* Purpose: Manages filter state by:
* - Determining if any filters are active. Useful when preventing save operations
* - Enabling/disabling save button based on filter state
* - Supporting save button state
* - Enabling filter-dependent features
*
* @returns {boolean} Whether any filters are currently active
*/
hasActiveFilters() {
return Object.keys(this.filters).length > 0;
}
/**
* Creates and adds a new empty row to the table.
*
* Purpose: Facilitates data entry by:
* - Creating proper structure for new rows
* - Setting appropriate default values
* - Maintaining data consistency
* - Scrolling to show the new row
*/
addNewRow() {
// Create an empty row object with the same structure as existing data
const emptyRow = Object.keys(this.data[0]).reduce((acc, key) => {
// Set default values based on column type
if (this.options.dropdownColumns[key]) {
// For dropdowns, use the first option as default
acc[key] = '';
// acc[key] = this.options.dropdownColumns[key][0] || '';
} else {
// For other columns, set empty string or 0 for numeric fields
const sampleValue = this.data[0][key];
acc[key] = typeof sampleValue === 'number' ? 0 : '';
}
return acc;
}, {});
// Add the new row to the data array
this.data.push(emptyRow);
this.originalData.push({...emptyRow});
// Refresh the table
this.container.innerHTML = '';
this.create();
// Scroll to the bottom to show the new row
const wrapper = this.container.querySelector('.table-wrapper');
wrapper.scrollTop = wrapper.scrollHeight;
}
/**
* Removes a row from the table.
*
* Purpose: Manages data removal by:
* - Confirming user intent
* - Updating both data sources
* - Preventing empty tables
* - Triggering proper UI updates
*
* @param {number} index - Index of the row to delete
*/
deleteRow(index) {
// Show confirmation dialog
if (!confirm('Are you sure you want to delete this row?')) {
return;
}
// Remove row from data arrays
this.data.splice(index, 1);
this.originalData = this.originalData.filter((_, i) => i !== index);
// Check if table is empty
if (this.data.length === 0) {
this.showNotification('Cannot delete last row', 'error');
return;
}
// Refresh the table
this.container.innerHTML = '';
this.create();
// Show notification
this.showNotification('Row deleted successfully', 'success');
// Mark as changed - don't update originalDataString yet
this.hasUnsavedChanges = true;
// Check for changes and update save button state
this.checkForChanges();
}
/**
* Manages data changes and save state.
*
* Purpose: Tracks modifications to table data to:
* - Enable/disable save functionality
* - Prevent data loss
* - Provide visual feedback about unsaved changes
* - Handle filtered state appropriately
*/
checkForChanges() {
const currentDataString = JSON.stringify(this.data);
const hasChanges = currentDataString !== this.originalDataString;
const isFiltered = this.hasActiveFilters();
const saveButton = document.getElementById('saveConfigBtn');
if (saveButton) {
// Disable if filtered or no changes
saveButton.disabled = isFiltered || !hasChanges;
// Update class based on state
if (isFiltered) {
saveButton.className = 'bg-green-500 text-white font-semibold py-2 px-4 rounded opacity-50 cursor-not-allowed';
saveButton.title = 'Cannot save while filters are active';
} else {
saveButton.className = hasChanges
? 'bg-green-500 hover:bg-green-600 text-white font-semibold py-2 px-4 rounded'
: 'bg-green-500 text-white font-semibold py-2 px-4 rounded opacity-50 cursor-not-allowed';
saveButton.title = hasChanges ? 'Save changes' : 'No changes to save';
}
}
this.hasUnsavedChanges = hasChanges;
}
/**
* Saves table configuration.
*
* Purpose: Persists table data while:
* - Validating required fields
* - Converting data to proper format
* - Handling server communication
* - Providing user feedback
* - Updating internal state
*
* @returns {Promise<boolean>} Success status of save operation
*/
async saveConfiguration() {
if (!this.options.saveEndpoint) {
console.error('Save endpoint not configured');
return false;
}
// Validate required fields before saving
const validationErrors = this.validateData();
if (validationErrors.length > 0) {
const errorMessage = validationErrors.join('\n');
this.showNotification(errorMessage, 'error');
console.error('Validation errors:', validationErrors);
return false;
}
try {
// Convert all data values to strings before formatting
const stringifiedData = this.data.map(row => this.stringifyValues(row));
// Format data using the provided formatter
const formattedData = this.options.saveDataFormatter(stringifiedData);
// Ensure formattedData is in the correct format for URLSearchParams
const params = new URLSearchParams();
if (typeof formattedData === 'object' && formattedData !== null) {
Object.entries(formattedData).forEach(([key, value]) => {
// Handle case where value might be an object/array
if (typeof value === 'object') {
params.append(key, JSON.stringify(value));
} else {
params.append(key, String(value));
}
});
}
const response = await fetch(this.options.saveEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Update original data after successful save (keep as strings)
this.originalData = stringifiedData;
this.originalDataString = JSON.stringify(stringifiedData);
this.hasUnsavedChanges = false;
this.showNotification('Configuration saved successfully', 'success');
return true;
} catch (error) {
console.error('Error saving configuration:', error);
this.showNotification('Error saving configuration', 'error');
return false;
}
}
/**
* Validates table data against requirements before sending a request.
*
* Purpose: Ensures data integrity by:
* - Checking required fields defined in `this.options.requiredColumns`
* - Generating user-friendly error messages
* - Preventing invalid data saves
* - Supporting row-level validation
*
* @returns {Array<string>} Array of validation error messages
*/
validateData() {
const errors = [];
this.data.forEach((row, index) => {
this.options.requiredColumns.forEach(column => {
if (!row[column] || row[column].trim() === '') {
const displayName = this.options.columnDisplayNames[column] || column;
errors.push(`Row ${index + 1}: ${displayName} is required \n`);
}
});
});
return errors;
}
/**
* Ensures consistent data types.
*
* Purpose: Converts all values to strings to:
* - Prevent type-related bugs
* - Ensure consistent filtering behavior
* - Maintain data integrity
* - Simplify data handling
*
* @param {Object} obj - Row data to be stringified
* @returns {Object} Row data with all values converted to strings
*/
stringifyValues(obj) {
const newObj = { ...obj };
Object.keys(newObj).forEach(key => {
// Convert all values to strings, handle null/undefined
newObj[key] = newObj[key] != null ? String(newObj[key]) : '';
});
return newObj;
}
/**
* Displays temporary user feedback messages.
*
* Purpose: Provides non-intrusive feedback by:
* - Showing success/error states
* - Auto-hiding after delay
* - Using consistent styling
* - Maintaining proper z-index layering
*
* @param {string} message - The message to display
* @param {string} type - The type of notification ('success' or 'error')
*/
showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-4 py-2 rounded shadow-lg z-50 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
} text-white`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
}
/**
* Exports table data to Excel format.
*
* Purpose: Enables data portability by:
* - Converting to spreadsheet format
* - Maintaining column order
* - Including current filters/sorting
* - Using friendly column names
* - Handling all data types
*/
exportToExcel() {
try {
// Get ordered columns for header row
const orderedColumns = this.getOrderedColumns();
// Create worksheet data
const wsData = [
// Header row with display names
orderedColumns.map(key => this.options.columnDisplayNames[key] || key)
];
// Add data rows
this.data.forEach(row => {
const rowData = orderedColumns.map(key => row[key]);
wsData.push(rowData);
});
// Create worksheet
const ws = XLSX.utils.aoa_to_sheet(wsData);
// Create workbook
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
// Save file
XLSX.writeFile(wb, this.options.excelFileName);
// Show success notification
this.showNotification('Table exported successfully', 'success');
} catch (error) {
console.error('Error exporting to Excel:', error);
this.showNotification('Error exporting to Excel', 'error');
}
}
/**
* Exports table data to JSON format.
*
* Purpose: Enables data interchange by:
* - Converting to standard JSON format
* - Including current filters/sorting
* - Maintaining data structure
* - Handling download process
*/
exportToJson() {
try {
// Use the current filtered/sorted data
const jsonData = JSON.stringify(this.data, null, 2);
// Create blob and download link
const blob = new Blob([jsonData], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.options.jsonFileName;
// Trigger download
document.body.appendChild(a);
a.click();
// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Show success notification
this.showNotification('JSON exported successfully', 'success');
} catch (error) {
console.error('Error exporting to JSON:', error);
this.showNotification('Error exporting to JSON', 'error');
}
}
/**
* Enables table content copying functionality.
*
* Purpose: Provides Excel-like copy behavior by:
* - Handling cell content extraction from inputs and dropdowns
* - Preserving proper column alignment
* - Removing unwanted characters (icons)
* - Creating tab-separated values for spreadsheet compatibility
*
* @param {HTMLElement} table - The table element to enable copy functionality on
*/
addCopyHandler(table) {
table.addEventListener('copy', (e) => {
e.preventDefault();
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// Get selected rows
const selectedRows = [];
const cells = range.cloneContents().querySelectorAll('th, td');
const columnsCount = table.rows[0].cells.length;
let currentRow = [];
cells.forEach((cell, index) => {
// Get cell content based on type