modern-table-js
Version:
Modern, lightweight, vanilla JavaScript table library with zero dependencies. 67% faster than DataTables with mobile-first responsive design.
1,472 lines (1,272 loc) • 231 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ModernTable = {}));
})(this, (function (exports) { 'use strict';
/**
* EventEmitter - Modern event system for ModernTable.js
*/
class EventEmitter {
constructor() {
this.events = new Map();
}
/**
* Add event listener
*/
on(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(callback);
return this;
}
/**
* Remove event listener
*/
off(event, callback) {
if (!this.events.has(event)) return this;
const callbacks = this.events.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
return this;
}
/**
* Emit event
*/
emit(event, ...args) {
if (!this.events.has(event)) return this;
this.events.get(event).forEach(callback => {
try {
callback(...args);
} catch (error) {
console.error(`Error in event listener for '${event}':`, error);
}
});
return this;
}
/**
* Add one-time event listener
*/
once(event, callback) {
const onceCallback = (...args) => {
callback(...args);
this.off(event, onceCallback);
};
return this.on(event, onceCallback);
}
/**
* Remove all listeners for event
*/
removeAllListeners(event) {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
}
/**
* ApiClient - Modern fetch-based HTTP client for ModernTable.js
*/
class ApiClient {
constructor(config = {}) {
// Handle string URL or object config
if (typeof config === 'string') {
this.config = {
url: config,
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
};
} else {
this.config = {
url: null,
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
...config
};
}
}
/**
* Make HTTP request with jQuery.ajax-like callbacks
*/
async request(params = {}) {
let config;
try {
// beforeSend callback
if (this.config.beforeSend) {
const shouldContinue = await this.config.beforeSend(params);
if (shouldContinue === false) {
return; // Abort request
}
}
config = await this.prepareRequest(params);
// Setup timeout if specified
const controller = new AbortController();
let timeoutId;
if (this.config.timeout) {
timeoutId = setTimeout(() => {
controller.abort();
}, this.config.timeout);
}
const response = await fetch(config.url, {
method: config.method,
headers: config.headers,
body: config.method !== 'GET' ? JSON.stringify(config.data) : null,
signal: controller.signal
});
// Clear timeout
if (timeoutId) {
clearTimeout(timeoutId);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
let data = await response.json();
// DataTables-compatible dataSrc transformation
if (this.config.dataSrc && typeof this.config.dataSrc === 'function') {
console.log('🔄 Applying dataSrc transformation');
data = this.config.dataSrc(data);
}
// success callback
if (this.config.success) {
await this.config.success(data, 'success', response);
}
return data;
} catch (error) {
// error callback
if (this.config.error) {
const result = await this.config.error(error, 'error', error.message);
if (result) return result; // Allow error callback to provide fallback data
}
// Legacy onError support
if (this.config.onError) {
return await this.config.onError(error);
}
throw error;
} finally {
// complete callback (always runs)
if (this.config.complete) {
await this.config.complete();
}
}
}
/**
* Prepare request configuration
*/
async prepareRequest(params) {
let config = {
url: this.buildUrl(params),
method: this.config.method || 'GET',
headers: { ...this.config.headers },
data: this.config.data ? this.config.data(params) : params
};
// Add CSRF token if available
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
if (csrfToken) {
config.headers['X-CSRF-TOKEN'] = csrfToken;
}
// Apply request interceptor
if (this.config.beforeRequest) {
config = await this.config.beforeRequest(config);
}
return config;
}
/**
* Build URL with query parameters
*/
buildUrl(params) {
const baseUrl = typeof this.config === 'string' ? this.config : this.config.url;
if (!baseUrl) {
throw new Error('API URL is not configured');
}
if (this.config.method === 'GET' && params && Object.keys(params).length > 0) {
const url = new URL(baseUrl, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
if (Array.isArray(value)) {
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
Object.entries(item).forEach(([subKey, subValue]) => {
if (typeof subValue === 'object' && subValue !== null) {
// Handle nested objects (like search: {value: 'x', regex: false})
Object.entries(subValue).forEach(([nestedKey, nestedValue]) => {
url.searchParams.append(`${key}[${index}][${subKey}][${nestedKey}]`, nestedValue);
});
} else {
url.searchParams.append(`${key}[${index}][${subKey}]`, subValue);
}
});
} else {
url.searchParams.append(`${key}[${index}]`, item);
}
});
} else if (typeof value === 'object' && value !== null) {
Object.entries(value).forEach(([subKey, subValue]) => {
if (typeof subValue === 'object' && subValue !== null) {
Object.entries(subValue).forEach(([nestedKey, nestedValue]) => {
url.searchParams.append(`${key}[${subKey}][${nestedKey}]`, nestedValue);
});
} else {
url.searchParams.append(`${key}[${subKey}]`, subValue);
}
});
} else {
url.searchParams.append(key, value);
}
}
});
return url.toString();
}
return baseUrl;
}
/**
* GET request
*/
async get(params) {
const originalMethod = this.config.method;
this.config.method = 'GET';
const result = await this.request(params);
this.config.method = originalMethod;
return result;
}
/**
* POST request
*/
async post(data) {
const originalMethod = this.config.method;
this.config.method = 'POST';
const result = await this.request(data);
this.config.method = originalMethod;
return result;
}
/**
* PUT request
*/
async put(data) {
const originalMethod = this.config.method;
this.config.method = 'PUT';
const result = await this.request(data);
this.config.method = originalMethod;
return result;
}
/**
* DELETE request
*/
async delete(data) {
const originalMethod = this.config.method;
this.config.method = 'DELETE';
const result = await this.request(data);
this.config.method = originalMethod;
return result;
}
}
/**
* StateManager.js - State persistence for ModernTable.js
* Sesuai master plan: core/StateManager.js (1KB)
*/
class StateManager {
constructor(table) {
this.table = table;
// Generate unique key based on element ID or create one
const tableId = this.table.element.id || `table_${Date.now()}`;
this.storageKey = `modernTable_${tableId}`;
this.duration = (this.table.options.stateDuration || 7200) * 1000; // Convert to milliseconds
}
/**
* Save current table state
*/
save() {
if (!this.table.options.stateSave) return;
const filters = this.table.components.filterPanel?.getFilters() || {};
const state = {
page: this.table.currentPage,
pageLength: this.table.options.pageLength,
search: this.table.searchInput?.value?.trim() || '',
order: this.table.plugins.sorting?.getCurrentSort() || null,
filters: filters,
columns: this.getColumnStates(),
selection: this.table.plugins.selection?.getSelectedRowIds() || [],
timestamp: Date.now()
};
try {
localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (error) {
console.warn('Failed to save table state:', error);
}
}
/**
* Load saved table state
*/
load() {
if (!this.table.options.stateSave) return null;
try {
const saved = localStorage.getItem(this.storageKey);
if (!saved) return null;
const state = JSON.parse(saved);
// Check if state is expired
if (Date.now() - state.timestamp > this.duration) {
this.clear();
return null;
}
return state;
} catch (error) {
console.warn('Failed to load table state:', error);
return null;
}
}
/**
* Apply saved state to table (async with delays)
*/
apply(state) {
if (!state) return;
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
this.applyStateWhenReady(state);
}, 100);
}
/**
* Apply saved state synchronously (for initial load)
*/
applySync(state) {
if (!state) return;
try {
// Apply page length immediately
if (state.pageLength) {
this.table.options.pageLength = state.pageLength;
}
// Apply search immediately
if (state.search) {
// Store search term for when input is ready
this.pendingSearch = state.search;
}
// Apply sorting immediately
if (state.order) {
// Store sort for when plugin is ready
this.pendingSort = state.order;
}
// Apply filters immediately
if (state.filters) {
// Store filters for when FilterPanel is ready
this.pendingFilters = state.filters;
}
// Apply column visibility immediately
if (state.columns) {
this.pendingColumns = state.columns;
}
// Apply selection immediately
if (state.selection) {
this.pendingSelection = state.selection;
}
// Apply page immediately
if (state.page) {
this.table.currentPage = state.page;
}
// Apply pending states after components are ready
setTimeout(() => {
this.applyPendingStates();
}, 50);
} catch (error) {
console.warn('Error applying state sync:', error);
}
}
/**
* Apply pending states after components are initialized
*/
applyPendingStates() {
try {
// Apply pending search
if (this.pendingSearch && this.table.searchInput) {
this.table.searchInput.value = this.pendingSearch;
// Trigger input event to show clear button
this.table.searchInput.dispatchEvent(new Event('input'));
// Don't clear pendingSearch immediately, keep it for buildRequestParams
setTimeout(() => {
this.pendingSearch = null;
}, 100);
}
// Apply pending sort
if (this.pendingSort && this.table.plugins?.sorting) {
if (typeof this.table.plugins.sorting.setSort === 'function') {
this.table.plugins.sorting.setSort(this.pendingSort.column, this.pendingSort.dir);
} else {
this.table.plugins.sorting.currentSort = this.pendingSort;
}
this.pendingSort = null;
}
// Apply length select after it's created
if (this.table.options.pageLength && this.table.lengthSelect) {
this.table.lengthSelect.value = this.table.options.pageLength;
}
// Apply pending filters using direct method
if (this.pendingFilters && this.table.components?.filterPanel) {
if (typeof this.table.components.filterPanel.setFilters === 'function') {
this.table.components.filterPanel.setFilters(this.pendingFilters);
} else {
this.applyFilters(this.pendingFilters);
}
this.pendingFilters = null;
}
// Apply pending column visibility
if (this.pendingColumns) {
this.applyColumnStates(this.pendingColumns);
this.pendingColumns = null;
}
// Apply pending selection after a longer delay to ensure data is rendered
if (this.pendingSelection && this.table.plugins?.selection) {
setTimeout(() => {
this.table.plugins.selection.restoreSelection(this.pendingSelection);
this.pendingSelection = null;
}, 300);
}
} catch (error) {
console.warn('Error applying pending states:', error);
}
}
/**
* Apply state when DOM elements are ready
*/
applyStateWhenReady(state) {
try {
// Apply page length FIRST
if (state.pageLength && this.table.lengthSelect) {
this.table.lengthSelect.value = state.pageLength;
this.table.options.pageLength = state.pageLength;
}
// Apply search
if (state.search && this.table.searchInput) {
this.table.searchInput.value = state.search;
}
// Apply sorting with error handling
if (state.order && this.table.plugins?.sorting) {
if (typeof this.table.plugins.sorting.setSort === 'function') {
this.table.plugins.sorting.setSort(state.order.column, state.order.dir);
} else {
// Fallback: set currentSort directly
this.table.plugins.sorting.currentSort = state.order;
}
}
// Apply filters with delay for FilterPanel
if (state.filters && this.table.components?.filterPanel) {
setTimeout(() => {
this.applyFilters(state.filters);
}, 200);
}
// Apply column states
if (state.columns) {
this.applyColumnStates(state.columns);
}
// Apply page (after other states)
if (state.page) {
this.table.currentPage = state.page;
}
} catch (error) {
console.warn('Error applying state:', error);
}
}
/**
* Get current column states
*/
getColumnStates() {
// Use columnVisibility state if available, otherwise check DOM
if (this.table.columnVisibility) {
const states = Object.keys(this.table.columnVisibility).map(index => ({
index: parseInt(index),
visible: this.table.columnVisibility[index] !== false
}));
return states;
}
// Fallback to DOM inspection
const states = [];
const headers = this.table.thead?.querySelectorAll('th[data-column]') || [];
headers.forEach((th, index) => {
states.push({
index: index,
visible: getComputedStyle(th).display !== 'none'
});
});
return states;
}
/**
* Apply column states
*/
applyColumnStates(states) {
try {
// Initialize columnVisibility if not exists
if (!this.table.columnVisibility) {
this.table.columnVisibility = {};
// Initialize all columns as visible first
this.table.options.columns.forEach((column, index) => {
this.table.columnVisibility[index] = true;
});
}
states.forEach(state => {
const columnIndex = state.index;
if (typeof columnIndex === 'number' && typeof state.visible === 'boolean') {
this.table.columnVisibility[columnIndex] = state.visible;
// Apply visibility immediately
if (this.table.applyColumnVisibility) {
this.table.applyColumnVisibility(columnIndex, state.visible);
}
}
});
// Force apply all column visibility after data is loaded
setTimeout(() => {
if (this.table.applyAllColumnVisibility) {
this.table.applyAllColumnVisibility();
}
}, 200);
} catch (error) {
console.warn('Error applying column states:', error);
}
}
/**
* Apply filters from state
*/
applyFilters(filters) {
try {
// Try immediate application first
const filterInputs = this.table.wrapper?.querySelectorAll('[data-filter]') || [];
if (filterInputs.length > 0) {
// Filters are ready, apply immediately
filterInputs.forEach(input => {
const filterKey = input.dataset.filter;
if (filters[filterKey]) {
input.value = filters[filterKey];
// Trigger change event to update filter state
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
} else {
// Filters not ready, use retry mechanism with shorter delays
const maxRetries = 3;
let retries = 0;
const tryApplyFilters = () => {
const filterInputs = this.table.wrapper?.querySelectorAll('[data-filter]') || [];
if (filterInputs.length === 0 && retries < maxRetries) {
retries++;
setTimeout(tryApplyFilters, 50); // Reduced from 100ms to 50ms
return;
}
if (filterInputs.length > 0) {
filterInputs.forEach(input => {
const filterKey = input.dataset.filter;
if (filters[filterKey]) {
input.value = filters[filterKey];
// Trigger change event to update filter state
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
};
tryApplyFilters();
}
} catch (error) {
console.warn('Error applying filters:', error);
}
}
/**
* Clear saved state
*/
clear() {
try {
localStorage.removeItem(this.storageKey);
} catch (error) {
console.warn('Failed to clear table state:', error);
}
}
/**
* Check if state saving is enabled
*/
isEnabled() {
return this.table.options.stateSave === true;
}
}
/**
* DOM Utilities for ModernTable.js
*/
/**
* Create element with attributes and content
*/
function createElement(tag, attributes = {}, content = '') {
const element = document.createElement(tag);
Object.entries(attributes).forEach(([key, value]) => {
if (key === 'className') {
element.className = value;
} else if (key === 'innerHTML') {
element.innerHTML = value;
} else if (key === 'textContent') {
element.textContent = value;
} else {
element.setAttribute(key, value);
}
});
if (content) {
element.innerHTML = content;
}
return element;
}
/**
* Add CSS classes
*/
function addClass(element, ...classes) {
element.classList.add(...classes);
}
/**
* Remove CSS classes
*/
function removeClass(element, ...classes) {
element.classList.remove(...classes);
}
/**
* Check if element has class
*/
function hasClass(element, className) {
return element.classList.contains(className);
}
/**
* Find element by selector
*/
function find(selector, context = document) {
return context.querySelector(selector);
}
/**
* Find all elements by selector
*/
function findAll(selector, context = document) {
return Array.from(context.querySelectorAll(selector));
}
/**
* Detect CSS framework
*/
function detectFramework() {
const body = document.body;
const html = document.documentElement;
// Check for Bootstrap
if (find('.container, .container-fluid') ||
hasClass(body, 'bootstrap') ||
find('link[href*="bootstrap"]')) {
return 'bootstrap';
}
// Check for Tailwind
if (hasClass(html, 'tailwind') ||
find('link[href*="tailwind"]') ||
find('[class*="bg-"], [class*="text-"], [class*="p-"], [class*="m-"]')) {
return 'tailwind';
}
// Check for Bulma
if (find('.container, .column') ||
find('link[href*="bulma"]')) {
return 'bulma';
}
return 'none';
}
/**
* Get framework-specific classes
*/
function getFrameworkClasses(framework = null) {
if (!framework) {
framework = detectFramework();
}
const classes = {
bootstrap: {
button: 'btn',
buttonPrimary: 'btn btn-primary',
buttonSecondary: 'btn btn-secondary',
input: 'form-control',
select: 'form-select',
table: 'table',
pagination: 'pagination',
pageItem: 'page-item',
pageLink: 'page-link'
},
tailwind: {
button: 'px-4 py-2 rounded',
buttonPrimary: 'bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600',
buttonSecondary: 'bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600',
input: 'border border-gray-300 rounded px-3 py-2',
select: 'border border-gray-300 rounded px-3 py-2',
table: 'min-w-full divide-y divide-gray-200',
pagination: 'flex space-x-1',
pageItem: '',
pageLink: 'px-3 py-2 border border-gray-300 rounded'
},
bulma: {
button: 'button',
buttonPrimary: 'button is-primary',
buttonSecondary: 'button is-light',
input: 'input',
select: 'select',
table: 'table',
pagination: 'pagination-list',
pageItem: '',
pageLink: 'pagination-link'
},
none: {
button: 'mt-button',
buttonPrimary: 'mt-button mt-button-primary',
buttonSecondary: 'mt-button mt-button-secondary',
input: 'mt-input',
select: 'mt-select',
table: 'mt-table',
pagination: 'mt-pagination',
pageItem: 'mt-page-item',
pageLink: 'mt-page-link'
}
};
return classes[framework] || classes.none;
}
/**
* Performance utilities for ModernTable.js
*/
/**
* Debounce function - delays execution until after delay
*/
function debounce(func, delay = 300) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
/**
* FilterPanel.js - Advanced filters component for ModernTable.js
* Sesuai master plan: components/FilterPanel.js (3KB)
*/
class FilterPanel {
constructor(table) {
this.table = table;
this.filters = {};
this.init();
}
init() {
if (this.table.options.filters && this.table.options.filters.length > 0) {
this.createFilterPanel();
}
}
/**
* Create filter panel
*/
createFilterPanel() {
this.filtersContainer = createElement('div', {
className: 'modern-table-filters mb-3 p-3 bg-body-secondary rounded border'
});
const filtersRow = createElement('div', {
className: 'd-flex flex-wrap gap-2 align-items-end'
});
this.table.options.filters.forEach(filter => {
// Create flex item with auto width based on content
const col = createElement('div', {
className: 'flex-shrink-0'
});
this.createFilter(filter, col);
filtersRow.appendChild(col);
});
this.filtersContainer.appendChild(filtersRow);
// Insert after toolbar if exists, otherwise before table
if (this.table.toolbar) {
this.table.toolbar.parentNode.insertBefore(this.filtersContainer, this.table.toolbar.nextSibling);
} else {
this.table.wrapper.insertBefore(this.filtersContainer, this.table.element);
}
}
/**
* Create individual filter
*/
createFilter(filter, container) {
switch (filter.type) {
case 'select':
this.createSelectFilter(filter, container);
break;
case 'text':
this.createTextFilter(filter, container);
break;
case 'date':
this.createDateFilter(filter, container);
break;
case 'daterange':
this.createDateRangeFilter(filter, container);
break;
case 'numberrange':
this.createNumberRangeFilter(filter, container);
break;
case 'clear':
this.createClearButton(filter, container);
break;
}
}
/**
* Create select filter
*/
createSelectFilter(filter, container) {
// Add label
if (filter.label) {
const label = createElement('label', {
className: 'form-label small mb-1',
textContent: filter.label
});
container.appendChild(label);
}
const select = createElement('select', {
className: 'form-select form-select-sm',
'data-filter': filter.column,
style: 'min-width: 120px; width: auto;'
});
filter.options.forEach(option => {
const optionEl = createElement('option', {
value: option.value,
textContent: option.text
});
select.appendChild(optionEl);
});
select.addEventListener('change', (e) => {
this.applyFilter(filter.column, e.target.value);
});
container.appendChild(select);
}
/**
* Create text filter
*/
createTextFilter(filter, container) {
const input = createElement('input', {
type: 'text',
className: 'form-control form-control-sm',
placeholder: filter.placeholder || `Filter ${filter.label}`,
'data-filter': filter.column
});
// Debounce text input
let timeout;
input.addEventListener('input', (e) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
this.applyFilter(filter.column, e.target.value);
}, 300);
});
container.appendChild(input);
}
/**
* Create single date filter
*/
createDateFilter(filter, container) {
// Add label
if (filter.label) {
const label = createElement('label', {
className: 'form-label small mb-1',
textContent: filter.label
});
container.appendChild(label);
}
const input = createElement('input', {
type: 'date',
className: 'form-control form-control-sm',
placeholder: filter.placeholder || `Filter ${filter.label}`,
'data-filter': filter.column,
style: 'width: 150px;'
});
input.addEventListener('change', (e) => {
// Smart date range logic: only trigger when end_date has value
if (filter.column === 'start_date') {
// Store start_date but don't trigger filter yet
if (e.target.value) {
this.filters[filter.column] = e.target.value;
} else {
delete this.filters[filter.column];
}
// Only trigger if end_date also has value
const endDateInput = document.querySelector('[data-filter="end_date"]');
if (endDateInput && endDateInput.value) {
this.table.currentPage = 1;
this.table.loadData();
}
} else if (filter.column === 'end_date') {
// Store end_date and trigger filter (regardless of start_date)
if (e.target.value) {
this.filters[filter.column] = e.target.value;
} else {
delete this.filters[filter.column];
}
this.table.currentPage = 1;
this.table.loadData();
} else {
// For single date filters, apply immediately
this.applyFilter(filter.column, e.target.value);
}
});
container.appendChild(input);
}
/**
* Create date range filter
*/
createDateRangeFilter(filter, container) {
// Add label
if (filter.label) {
const label = createElement('label', {
className: 'form-label small mb-1',
textContent: filter.label
});
container.appendChild(label);
}
const wrapper = createElement('div', {
className: 'd-flex gap-1'
});
const fromInput = createElement('input', {
type: 'date',
className: 'form-control form-control-sm',
placeholder: 'From',
'data-filter': `${filter.column}_from`,
style: 'width: 140px;'
});
const toInput = createElement('input', {
type: 'date',
className: 'form-control form-control-sm',
placeholder: 'To',
'data-filter': `${filter.column}_to`,
style: 'width: 140px;'
});
fromInput.addEventListener('change', () => {
this.applyDateRangeFilter(filter.column, fromInput.value, toInput.value);
});
toInput.addEventListener('change', () => {
this.applyDateRangeFilter(filter.column, fromInput.value, toInput.value);
});
wrapper.appendChild(fromInput);
wrapper.appendChild(toInput);
container.appendChild(wrapper);
}
/**
* Create number range filter
*/
createNumberRangeFilter(filter, container) {
const wrapper = createElement('div', {
className: 'd-flex gap-1'
});
const minInput = createElement('input', {
type: 'number',
className: 'form-control form-control-sm',
placeholder: `Min ${filter.label}`,
min: filter.min || 0,
max: filter.max || 999999,
'data-filter': `${filter.column}_min`
});
const maxInput = createElement('input', {
type: 'number',
className: 'form-control form-control-sm',
placeholder: `Max ${filter.label}`,
min: filter.min || 0,
max: filter.max || 999999,
'data-filter': `${filter.column}_max`
});
minInput.addEventListener('change', () => {
this.applyNumberRangeFilter(filter.column, minInput.value, maxInput.value);
});
maxInput.addEventListener('change', () => {
this.applyNumberRangeFilter(filter.column, minInput.value, maxInput.value);
});
wrapper.appendChild(minInput);
wrapper.appendChild(maxInput);
container.appendChild(wrapper);
}
/**
* Apply single filter
*/
applyFilter(column, value) {
if (value && value.trim()) {
this.filters[column] = value.trim();
} else {
delete this.filters[column];
}
this.table.currentPage = 1;
// Save state after filter
if (this.table.stateManager && this.table.stateManager.isEnabled()) {
this.table.stateManager.save();
}
this.table.loadData();
}
/**
* Apply date range filter
*/
applyDateRangeFilter(column, fromDate, toDate) {
if (fromDate) {
this.filters[`${column}_from`] = fromDate;
} else {
delete this.filters[`${column}_from`];
}
if (toDate) {
this.filters[`${column}_to`] = toDate;
} else {
delete this.filters[`${column}_to`];
}
this.table.currentPage = 1;
// Save state after filter
if (this.table.stateManager && this.table.stateManager.isEnabled()) {
this.table.stateManager.save();
}
this.table.loadData();
}
/**
* Apply number range filter
*/
applyNumberRangeFilter(column, minValue, maxValue) {
if (minValue && !isNaN(minValue)) {
this.filters[`${column}_min`] = minValue;
} else {
delete this.filters[`${column}_min`];
}
if (maxValue && !isNaN(maxValue)) {
this.filters[`${column}_max`] = maxValue;
} else {
delete this.filters[`${column}_max`];
}
this.table.currentPage = 1;
// Save state after filter
if (this.table.stateManager && this.table.stateManager.isEnabled()) {
this.table.stateManager.save();
}
this.table.loadData();
}
/**
* Get current filters
*/
getFilters() {
return { ...this.filters };
}
/**
* Set filters from state and trigger reload
*/
setFilters(filters) {
this.filters = { ...filters };
// Update filter inputs to match state
Object.keys(filters).forEach(filterKey => {
const input = find(`[data-filter="${filterKey}"]`, this.filtersContainer);
if (input && filters[filterKey]) {
input.value = filters[filterKey];
// Trigger change event to update UI
if (input.tagName === 'SELECT') {
input.dispatchEvent(new Event('change'));
}
}
});
// Trigger reload to apply restored filters
setTimeout(() => {
this.table.loadData();
}, 100);
}
/**
* Create clear button
*/
createClearButton(filter, container) {
const button = createElement('button', {
type: 'button',
className: filter.className || 'btn btn-outline-danger btn-sm',
innerHTML: `<i class="${filter.icon || 'fas fa-eraser'}"></i> ${filter.label || 'Clear All'}`
});
button.addEventListener('click', (e) => {
e.preventDefault();
if (filter.action && typeof filter.action === 'function') {
filter.action();
} else {
this.clearFilters();
}
});
container.appendChild(button);
}
/**
* Clear all filters
*/
clearFilters(skipReload = false) {
this.filters = {};
// Reset all filter inputs
const filterInputs = findAll('[data-filter]', this.filtersContainer);
filterInputs.forEach(input => {
if (input.tagName === 'SELECT') {
input.selectedIndex = 0;
} else {
input.value = '';
}
});
this.table.currentPage = 1;
if (!skipReload) {
this.table.loadData();
}
}
/**
* Destroy filter panel
*/
destroy() {
if (this.filtersContainer && this.filtersContainer.parentNode) {
this.filtersContainer.parentNode.removeChild(this.filtersContainer);
}
}
}
/**
* Export utilities for ModernTable.js
*/
/**
* Download file helper
*/
function downloadFile(content, mimeType, filename) {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
/**
* Format data for print
*/
function formatPrint(data, title = 'Table Data') {
let html = `
<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f5f5f5; font-weight: bold; }
tr:nth-child(even) { background-color: #f9f9f9; }
@media print {
body { margin: 0; }
table { font-size: 12px; }
}
</style>
</head>
<body>
<h1>${title}</h1>
<table>
`;
// Add headers
if (data.headers && data.headers.length > 0) {
html += '<thead><tr>';
data.headers.forEach(header => {
html += `<th>${escapeHtml(String(header))}</th>`;
});
html += '</tr></thead>';
}
// Add data rows
html += '<tbody>';
data.rows.forEach(row => {
html += '<tr>';
row.forEach(cell => {
html += `<td>${escapeHtml(String(cell || ''))}</td>`;
});
html += '</tr>';
});
html += '</tbody>';
html += `
</table>
<p style="margin-top: 20px; font-size: 12px; color: #666;">
Generated on ${new Date().toLocaleString()}
</p>
</body>
</html>
`;
return html;
}
/**
* Copy data to clipboard
*/
async function copyToClipboard(data) {
const text = formatClipboard(data);
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.warn('Clipboard API failed, using fallback');
}
}
// Fallback for older browsers
return copyToClipboardFallback(text);
}
/**
* Format data for clipboard (tab-separated)
*/
function formatClipboard(data) {
const lines = [];
// Add headers
if (data.headers && data.headers.length > 0) {
lines.push(data.headers.join('\t'));
}
// Add data rows
data.rows.forEach(row => {
lines.push(row.map(cell => String(cell || '')).join('\t'));
});
return lines.join('\n');
}
/**
* Fallback clipboard copy for older browsers
*/
function copyToClipboardFallback(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
return successful;
} catch (err) {
document.body.removeChild(textarea);
return false;
}
}
/**
* Escape HTML characters
*/
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Show notification
*/
function showNotification(message, type = 'success') {
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto remove after 3 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
/**
* ExportPlugin.js - Export functionality plugin for ModernTable.js
* Sesuai master plan: plugins/ExportPlugin.js (3KB)
*/
class ExportPlugin {
constructor(table) {
this.table = table;
this.init();
}
init() {
// Plugin i