UNPKG

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
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