@liedekef/ftable
Version:
Modern, lightweight, jQuery-free CRUD table for dynamic AJAX-powered tables.
1,518 lines (1,276 loc) • 167 kB
JavaScript
const FTABLE_DEFAULT_MESSAGES = {
serverCommunicationError: 'An error occurred while communicating to the server.',
loadingMessage: 'Loading records...',
noDataAvailable: 'No data available!',
addNewRecord: 'Add new record',
editRecord: 'Edit record',
areYouSure: 'Are you sure?',
deleteConfirmation: 'This record will be deleted. Are you sure?',
save: 'Save',
saving: 'Saving',
cancel: 'Cancel',
deleteText: 'Delete',
deleting: 'Deleting',
error: 'An error has occured',
close: 'Close',
cannotLoadOptionsFor: 'Cannot load options for field {0}!',
pagingInfo: 'Showing {0}-{1} of {2}',
canNotDeletedRecords: 'Can not delete {0} of {1} records!',
deleteProgress: 'Deleting {0} of {1} records, processing...',
pageSizeChangeLabel: 'Row count',
gotoPageLabel: 'Go to page',
sortingInfoPrefix: 'Sorting applied: ',
sortingInfoSuffix: '', // optional
ascending: 'Ascending',
descending: 'Descending',
sortingInfoNone: 'No sorting applied',
resetSorting: 'Reset sorting',
csvExport: 'CSV',
printTable: '🖨️ Print',
cloneRecord: 'Clone Record',
resetTable: 'Reset table',
resetTableConfirm: 'This will reset all columns, pagesize, sorting to their defaults. Do you want to continue?',
resetSearch: 'Reset'
};
class FTableOptionsCache {
constructor() {
this.cache = new Map();
}
generateKey(url, params) {
const sortedParams = Object.keys(params || {})
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
return `${url}?${sortedParams}`;
}
get(url, params) {
const key = this.generateKey(url, params);
return this.cache.get(key);
}
set(url, params, data) {
const key = this.generateKey(url, params);
this.cache.set(key, data);
}
clear(url = null, params = null) {
if (url) {
if (params) {
const key = this.generateKey(url, params);
this.cache.delete(key);
} else {
// Clear all entries that start with this URL
const urlPrefix = url.split('?')[0];
for (const [key] of this.cache) {
if (key.startsWith(urlPrefix)) {
this.cache.delete(key);
}
}
}
} else {
this.cache.clear();
}
}
size() {
return this.cache.size;
}
}
class FTableEventEmitter {
constructor() {
this.events = {};
}
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
return this;
}
once(event, callback) {
// Create a wrapper that removes itself after first call
const wrapper = (...args) => {
this.off(event, wrapper);
callback.apply(this, args);
};
// Store reference to wrapper so it can be removed
wrapper.fn = callback; // for off() to match
this.on(event, wrapper);
return this;
}
emit(event, data = {}) {
if (this.events[event]) {
this.events[event].forEach(callback => callback(data));
}
return this;
}
off(event, callback) {
if (this.events[event]) {
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
return this;
}
}
class FTableLogger {
static LOG_LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
NONE: 4
};
constructor(level = FTableLogger.LOG_LEVELS.WARN) {
this.level = level;
}
log(level, message) {
if (!window.console || level < this.level) return;
const levelName = Object.keys(FTableLogger.LOG_LEVELS)
.find(key => FTableLogger.LOG_LEVELS[key] === level);
console.log(`fTable ${levelName}: ${message}`);
}
debug(message) { this.log(FTableLogger.LOG_LEVELS.DEBUG, message); }
info(message) { this.log(FTableLogger.LOG_LEVELS.INFO, message); }
warn(message) { this.log(FTableLogger.LOG_LEVELS.WARN, message); }
error(message) { this.log(FTableLogger.LOG_LEVELS.ERROR, message); }
}
class FTableDOMHelper {
static create(tag, options = {}) {
const element = document.createElement(tag);
if (options.className) {
element.className = options.className;
}
if (options.style) {
element.style.cssText = options.style;
}
if (options.attributes) {
Object.entries(options.attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
}
if (options.text) {
element.textContent = options.text;
}
if (options.html) {
element.innerHTML = options.html;
}
if (options.parent) {
options.parent.appendChild(element);
}
return element;
}
static find(selector, parent = document) {
return parent.querySelector(selector);
}
static findAll(selector, parent = document) {
return Array.from(parent.querySelectorAll(selector));
}
static addClass(element, className) {
element.classList.add(...className.split(' '));
}
static removeClass(element, className) {
element.classList.remove(...className.split(' '));
}
static toggleClass(element, className) {
element.classList.toggle(className);
}
static show(element) {
element.style.display = '';
}
static hide(element) {
element.style.display = 'none';
}
static escapeHtml(text) {
if (!text) return text;
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, m => map[m]);
}
}
class FTableHttpClient {
static async request(url, options = {}) {
const defaults = {
method: 'GET',
headers: {}
};
const config = { ...defaults, ...options };
// Merge headers properly
if (options.headers) {
config.headers = { ...defaults.headers, ...options.headers };
}
try {
const response = await fetch(url, config);
if (response.status === 401) {
throw new Error('Unauthorized');
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Try to parse as JSON, fallback to text
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return { Result: 'OK', Message: text };
}
}
} catch (error) {
throw error;
}
}
static async get(url, params = {}) {
// Handle relative URLs by using the current page's base
let fullUrl = new URL(url, window.location.href);
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined) {
return; // Skip null or undefined values
}
if (Array.isArray(value)) {
// Clean key: remove trailing [] if present
const cleanKey = key.replace(/\[\]$/, '');
const paramKey = cleanKey + '[]'; // Always use [] suffix once
// Append each item in the array with the same key
// This generates query strings like `key=val1&key=val2&key=val3`
value.forEach(item => {
if (item !== null && item !== undefined) { // Ensure array items are also not null/undefined
fullUrl.searchParams.append(paramKey, item);
}
});
} else {
// Append single values normally
fullUrl.searchParams.append(key, value);
}
});
return this.request(fullUrl.toString(), {
method: 'GET',
headers: { 'Content-Type': 'application/x-www-form-urlencoded'}
});
}
static async post(url, data = {}) {
// Handle relative URLs
let fullUrl = new URL(url, window.location.href);
let formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value === null || value === undefined) {
return; // Skip null or undefined values
}
if (Array.isArray(value)) {
// Clean key: remove trailing [] if present
const cleanKey = key.replace(/\[\]$/, '');
const paramKey = cleanKey + '[]'; // Always use [] suffix once
// Append each item in the array with the same key
// This generates query strings like `key=val1&key=val2&key=val3`
value.forEach(item => {
if (item !== null && item !== undefined) { // Ensure array items are also not null/undefined
formData.append(paramKey, item);
}
});
} else {
// Append single values normally
formData.append(key, value);
}
});
return this.request(fullUrl.toString(), {
method: 'POST',
body: formData
});
}
}
class FTableUserPreferences {
constructor(prefix, method = 'localStorage') {
this.prefix = prefix;
this.method = method;
}
set(key, value) {
const fullKey = `${this.prefix}${key}`;
if (this.method === 'localStorage') {
localStorage.setItem(fullKey, value);
} else {
// Cookie fallback
const expireDate = new Date();
expireDate.setDate(expireDate.getDate() + 30);
document.cookie = `${fullKey}=${value}; expires=${expireDate.toUTCString()}; path=/`;
}
}
get(key) {
const fullKey = `${this.prefix}${key}`;
if (this.method === 'localStorage') {
return localStorage.getItem(fullKey);
} else {
// Cookie fallback
const name = fullKey + "=";
const decodedCookie = decodeURIComponent(document.cookie);
const ca = decodedCookie.split(';');
for (let c of ca) {
while (c.charAt(0) === ' ') {
c = c.substring(1);
}
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
}
}
return null;
}
}
remove(key) {
const fullKey = `${this.prefix}${key}`;
if (this.method === 'localStorage') {
localStorage.removeItem(fullKey);
} else {
document.cookie = `${fullKey}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
}
generatePrefix(tableId, fieldNames) {
const simpleHash = (value) => {
let hash = 0;
if (value.length === 0) return hash;
for (let i = 0; i < value.length; i++) {
const ch = value.charCodeAt(i);
hash = ((hash << 5) - hash) + ch;
hash = hash & hash;
}
return hash;
};
let strToHash = tableId ? `${tableId}#` : '';
strToHash += fieldNames.join('$') + '#c' + fieldNames.length;
return `ftable#${simpleHash(strToHash)}`;
}
}
class FtableModal {
constructor(options = {}) {
this.options = {
title: 'Modal',
content: '',
buttons: [],
className: 'ftable-modal',
parent: document.body,
...options
};
this.overlay = null;
this.modal = null;
this.isOpen = false;
}
create() {
// Create overlay
this.overlay = FTableDOMHelper.create('div', {
className: 'ftable-modal-overlay',
parent: this.options.parent
});
// Create modal
this.modal = FTableDOMHelper.create('div', {
className: `ftable-modal ${this.options.className}`,
parent: this.overlay
});
// Header
const header = FTableDOMHelper.create('h2', {
className: 'ftable-modal-header',
text: this.options.title,
parent: this.modal
});
// Close button
const closeBtn = FTableDOMHelper.create('span', {
className: 'ftable-modal-close',
html: '×',
parent: this.modal
});
closeBtn.addEventListener('click', () => this.close());
// Body
const body = FTableDOMHelper.create('div', {
className: 'ftable-modal-body',
parent: this.modal
});
if (typeof this.options.content === 'string') {
body.innerHTML = this.options.content;
} else {
body.appendChild(this.options.content);
}
// Footer with buttons
if (this.options.buttons.length > 0) {
const footer = FTableDOMHelper.create('div', {
className: 'ftable-modal-footer',
parent: this.modal
});
this.options.buttons.forEach(button => {
const btn = FTableDOMHelper.create('button', {
className: `ftable-dialog-button ${button.className || ''}`,
html: `<span>${button.text}</span>`,
parent: footer
});
if (button.onClick) {
btn.addEventListener('click', button.onClick);
}
});
}
// Close on overlay click
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) {
this.close();
}
});
this.hide();
return this;
}
show() {
if (!this.modal) this.create();
this.overlay.style.display = 'flex';
this.isOpen = true;
return this;
}
hide() {
if (this.overlay) {
this.overlay.style.display = 'none';
}
this.isOpen = false;
return this;
}
close() {
this.hide();
if (this.options.onClose) {
this.options.onClose();
}
return this;
}
destroy() {
if (this.overlay) {
this.overlay.remove();
}
this.isOpen = false;
return this;
}
setContent(content) {
this.options.content = content;
const body = this.modal.querySelector('.ftable-modal-body');
if (!body) return;
// Clear old content
body.innerHTML = '';
if (typeof content === 'string') {
body.innerHTML = content;
} else {
body.appendChild(content);
}
}
}
class FTableFormBuilder {
constructor(options) {
this.options = options;
this.dependencies = new Map(); // Track field dependencies
this.optionsCache = new FTableOptionsCache();
this.originalFieldOptions = new Map(); // Store original field.options
}
// Store original field options before any resolution
storeOriginalFieldOptions() {
if (this.originalFieldOptions.size > 0) return; // Already stored
Object.entries(this.options.fields).forEach(([fieldName, field]) => {
if (field.options && (typeof field.options === 'function' || typeof field.options === 'string')) {
this.originalFieldOptions.set(fieldName, field.options);
}
});
}
shouldIncludeField(field, formType) {
if (formType === 'create') {
return field.create !== false && !(field.key === true && field.create !== true);
} else if (formType === 'edit') {
return field.edit !== false;
}
return true;
}
createFieldContainer(fieldName, field, record, formType) {
const container = FTableDOMHelper.create('div', {
className: 'ftable-input-field-container',
attributes: {
id: `ftable-input-field-container-div-${fieldName}`,
}
});
// Label
const label = FTableDOMHelper.create('div', {
className: 'ftable-input-label',
text: field.inputTitle || field.title,
parent: container
});
// Input
const inputContainer = this.createInput(fieldName, field, record[fieldName], formType);
container.appendChild(inputContainer);
return container;
}
/*async resolveAllFieldOptions(fieldValues) {
// Store original options before first resolution
this.storeOriginalFieldOptions();
const promises = Object.entries(this.options.fields).map(async ([fieldName, field]) => {
// Use original options if we have them, otherwise use current field.options
const originalOptions = this.originalFieldOptions.get(fieldName) || field.options;
if (originalOptions && (typeof originalOptions === 'function' || typeof originalOptions === 'string')) {
try {
// Pass fieldValues as dependedValues for dependency resolution
const params = { dependedValues: fieldValues };
// Resolve using original options, not the possibly already-resolved ones
const tempField = { ...field, options: originalOptions };
const resolved = await this.resolveOptions(tempField, params);
field.options = resolved; // Replace with resolved data
} catch (err) {
console.error(`Failed to resolve options for ${fieldName}:`, err);
}
}
});
await Promise.all(promises);
}*/
async resolveNonDependantFieldOptions(fieldValues) {
// Store original options before first resolution
this.storeOriginalFieldOptions();
const promises = Object.entries(this.options.fields).map(async ([fieldName, field]) => {
// Use original options if we have them, otherwise use current field.options
if (field.dependsOn) {
return;
}
const originalOptions = this.originalFieldOptions.get(fieldName) || field.options;
if (originalOptions && (typeof originalOptions === 'function' || typeof originalOptions === 'string')) {
try {
// Pass fieldValues as dependedValues for dependency resolution
const params = { dependedValues: fieldValues };
// Resolve using original options, not the possibly already-resolved ones
const tempField = { ...field, options: originalOptions };
const resolved = await this.resolveOptions(tempField, params);
field.options = resolved; // Replace with resolved data
} catch (err) {
console.error(`Failed to resolve options for ${fieldName}:`, err);
}
}
});
await Promise.all(promises);
}
async createForm(formType = 'create', record = {}) {
this.currentFormRecord = record;
// Pre-resolve all options for fields depending on nothing, the others are handled down the road when dependancies are calculated
//await this.resolveAllFieldOptions(record);
await this.resolveNonDependantFieldOptions(record);
const form = FTableDOMHelper.create('form', {
className: `ftable-dialog-form ftable-${formType}-form`
});
// Build dependency map first
this.buildDependencyMap();
Object.entries(this.options.fields).forEach(([fieldName, field]) => {
if (this.shouldIncludeField(field, formType)) {
const fieldContainer = this.createFieldContainer(fieldName, field, record, formType);
form.appendChild(fieldContainer);
}
});
// Set up dependency listeners after all fields are created
this.setupDependencyListeners(form);
return form;
}
buildDependencyMap() {
this.dependencies.clear();
Object.entries(this.options.fields).forEach(([fieldName, field]) => {
if (field.dependsOn) {
// Normalize dependsOn to array
let dependsOnFields;
if (typeof field.dependsOn === 'string') {
// Handle CSV: 'field1, field2' → ['field1', 'field2']
dependsOnFields = field.dependsOn
.split(',')
.map(name => name.trim())
.filter(name => name);
} else {
return; // Invalid type
}
// Register this field as dependent on each master
dependsOnFields.forEach(dependsOnField => {
if (!this.dependencies.has(dependsOnField)) {
this.dependencies.set(dependsOnField, []);
}
this.dependencies.get(dependsOnField).push(fieldName);
});
}
});
}
setupDependencyListeners(form) {
// Collect all master fields (any field that is depended on)
const masterFieldNames = Array.from(this.dependencies.keys());
masterFieldNames.forEach(masterFieldName => {
const masterInput = form.querySelector(`[name="${masterFieldName}"]`);
if (!masterInput) return;
// Listen for changes
masterInput.addEventListener('change', () => {
// Re-evaluate dependent fields (they’ll check their own dependsOn)
this.handleDependencyChange(form, masterFieldName);
});
});
// Trigger initial update
this.handleDependencyChange(form);
}
async resolveOptions(field, params = {}) {
if (!field.options) return [];
// Case 1: Direct options (array or object)
if (Array.isArray(field.options) || typeof field.options === 'object') {
return field.options;
}
let result;
// Create a mutable flag for cache clearing
let noCache = false;
// Enhance params with clearCache() method
const enhancedParams = {
...params,
clearCache: () => { noCache = true; }
};
if (typeof field.options === 'function') {
result = await field.options(enhancedParams);
//result = await field.options(params); // Can return string or { url, noCache }
} else if (typeof field.options === 'string') {
result = field.options;
} else {
return [];
}
// --- Handle result ---
const isObjectResult = result && typeof result === 'object' && result.url;
const url = isObjectResult ? result.url : result;
noCache = isObjectResult && result.noCache !== undefined ? result.noCache : noCache;
if (typeof url !== 'string') return [];
// Only use cache if noCache is NOT set
if (!noCache) {
const cached = this.optionsCache.get(url, {});
if (cached) return cached;
}
try {
const response = this.options.forcePost
? await FTableHttpClient.post(url)
: await FTableHttpClient.get(url);
const options = response.Options || response.options || response || [];
// Only cache if noCache is false
if (!noCache) {
this.optionsCache.set(url, {}, options);
}
return options;
} catch (error) {
console.error(`Failed to load options from ${url}:`, error);
return [];
}
}
clearOptionsCache(url = null, params = null) {
this.optionsCache.clear(url, params);
}
async handleDependencyChange(form, changedFieldname='') {
// Build dependedValues: { field1: value1, field2: value2 }
const dependedValues = {};
// Get all field values from the form
for (const [fieldName, field] of Object.entries(this.options.fields)) {
const input = form.querySelector(`[name="${fieldName}"]`);
if (input) {
if (input.type === 'checkbox') {
dependedValues[fieldName] = input.checked ? '1' : '0';
} else {
dependedValues[fieldName] = input.value;
}
}
}
// Determine form context
const formType = form.classList.contains('ftable-create-form') ? 'create' : 'edit';
const record = this.currentFormRecord || {};
// Prepare base params for options function
const baseParams = {
record,
source: formType,
form, // DOM form element
dependedValues
};
// Update each dependent field
for (const [fieldName, field] of Object.entries(this.options.fields)) {
if (!field.dependsOn) continue;
if (changedFieldname !== '') {
let dependsOnFields = field.dependsOn
.split(',')
.map(name => name.trim())
.filter(name => name);
if (!dependsOnFields.includes(changedFieldname)) {
continue;
}
}
const input = form.querySelector(`[name="${fieldName}"]`);
if (!input || !this.shouldIncludeField(field, formType)) continue;
try {
// Clear current options
if (input.tagName === 'SELECT') {
input.innerHTML = '<option value="">Loading...</option>';
} else if (input.tagName === 'INPUT' && input.list) {
const datalist = document.getElementById(input.list.id);
if (datalist) datalist.innerHTML = '';
}
// Build params with full context
const params = {
...baseParams,
// Specific for this field
dependsOnField: field.dependsOn,
dependsOnValue: dependedValues[field.dependsOn]
};
// Use original options for dependent fields, not the resolved ones
const originalOptions = this.originalFieldOptions.get(fieldName) || field.options;
const tempField = { ...field, options: originalOptions };
// Resolve options with full context using original options
const newOptions = await this.resolveOptions(tempField, params);
// Populate
if (input.tagName === 'SELECT') {
this.populateSelectOptions(input, newOptions, '');
} else if (input.tagName === 'INPUT' && input.list) {
this.populateDatalistOptions(input.list, newOptions);
}
input.dispatchEvent(new Event('change', { bubbles: true }));
} catch (error) {
console.error(`Error loading options for ${fieldName}:`, error);
if (input.tagName === 'SELECT') {
input.innerHTML = '<option value="">Error</option>';
}
}
}
}
parseInputAttributes(inputAttributes) {
if (typeof inputAttributes === 'string') {
const parsed = {};
const regex = /(\w+)(?:=("[^"]*"|'[^']*'|\S+))?/g;
let match;
while ((match = regex.exec(inputAttributes)) !== null) {
const key = match[1];
const value = match[2] ? match[2].replace(/^["']|["']$/g, '') : '';
parsed[key] = value === '' ? 'true' : value;
}
return parsed;
}
return inputAttributes || {};
}
createInput(fieldName, field, value, formType) {
const container = FTableDOMHelper.create('div', {
className: `ftable-input ftable-${field.type || 'text'}-input`
});
let input;
if (value == null || value == undefined ) {
value = field.defaultValue;
}
// Auto-detect select type if options are provided
if (!field.type && field.options) {
field.type = 'select';
}
// Create the input based on type
switch (field.type) {
case 'hidden':
input = this.createHiddenInput(fieldName, field, value);
break;
case 'textarea':
input = this.createTextarea(fieldName, field, value);
break;
case 'select':
input = this.createSelect(fieldName, field, value);
break;
case 'checkbox':
input = this.createCheckbox(fieldName, field, value);
break;
case 'radio':
input = this.createRadioGroup(fieldName, field, value);
break;
case 'datalist':
input = this.createDatalistInput(fieldName, field, value);
break;
case 'file':
input = this.createFileInput(fieldName, field, value);
break;
case 'date':
case 'datetime-local':
input = this.createDateInput(fieldName, field, value);
break;
default:
input = this.createTypedInput(fieldName, field, value);
}
// Allow field.input function to customize or replace the input
if (typeof field.input === 'function') {
const data = {
field: field,
record: this.currentFormRecord,
inputField: input,
formType: formType
};
const result = field.input(data);
// If result is a string, set as innerHTML
if (typeof result === 'string') {
container.innerHTML = result;
}
// If result is a DOM node, append it
else if (result instanceof Node) {
container.appendChild(result);
}
// Otherwise, fallback to default
else {
container.appendChild(input);
}
} else {
// No custom input function — just add the default input
container.appendChild(input);
}
// Add explanation if provided
if (field.explain) {
const explain = FTableDOMHelper.create('div', {
className: 'ftable-field-explain',
html: `<small>${field.explain}</small>`,
parent: container
});
}
return container;
}
createDateInput(fieldName, field, value) {
// Check if FDatepicker is available
if (typeof FDatepicker !== 'undefined') {
const dateFormat = field.dateFormat || this.options.defaultDateFormat;
const container = document.createElement('div');
// Create hidden input
const hiddenInput = FTableDOMHelper.create('input', {
attributes: {
id: 'real-' + fieldName,
type: 'hidden',
value: value || '',
name: fieldName
}
});
// Create visible input
const visibleInput = FTableDOMHelper.create('input', {
className: field.inputClass || 'datepicker-input',
attributes: {
id: 'Edit-' + fieldName,
type: 'text',
'data-date': value,
placeholder: field.placeholder || '',
readOnly: true
}
});
// Set any additional attributes
if (field.inputAttributes) {
Object.keys(field.inputAttributes).forEach(key => {
visibleInput.setAttribute(key, field.inputAttributes[key]);
});
}
// Append both inputs
container.appendChild(hiddenInput);
container.appendChild(visibleInput);
// Apply FDatepicker
const picker = new FDatepicker(visibleInput, {
format: dateFormat,
altField: 'real-' + fieldName,
altFormat: 'Y-m-d'
});
return container;
} else {
return createTypedInput(fieldName, field, value);
}
}
createTypedInput(fieldName, field, value) {
const inputType = field.type || 'text';
const attributes = {
type: inputType,
id: `Edit-${fieldName}`,
placeholder: field.placeholder || '',
value: value || ''
};
// extra check for name and multiple
let name = fieldName;
// Apply inputAttributes from field definition
if (field.inputAttributes) {
let hasMultiple = false;
const parsed = this.parseInputAttributes(field.inputAttributes);
Object.assign(attributes, parsed);
hasMultiple = parsed.multiple !== undefined && parsed.multiple !== false;
if (hasMultiple) {
name = `${fieldName}[]`;
}
}
attributes.name = name;
const input = FTableDOMHelper.create('input', {
className: field.inputClass || '',
attributes: attributes
});
// Prevent form submit on Enter, trigger change instead
input.addEventListener('keypress', (e) => {
const keyPressed = e.keyCode || e.which;
if (keyPressed === 13) { // Enter key
e.preventDefault();
input.dispatchEvent(new Event('change', { bubbles: true }));
return false;
}
});
return input;
}
createDatalistInput(fieldName, field, value) {
const input = FTableDOMHelper.create('input', {
attributes: {
type: 'text',
name: fieldName,
id: `Edit-${fieldName}`,
placeholder: field.placeholder || '',
value: value || '',
class: field.inputClass || '',
list: `${fieldName}-datalist`
}
});
// Create the datalist element
const datalist = FTableDOMHelper.create('datalist', {
attributes: {
id: `${fieldName}-datalist`
}
});
// Populate datalist options
if (field.options) {
this.populateDatalistOptions(datalist, field.options);
}
// Append datalist to the document body or form
document.body.appendChild(datalist);
// Store reference for cleanup
input.datalistElement = datalist;
return input;
}
populateDatalistOptions(datalist, options) {
datalist.innerHTML = ''; // Clear existing options
if (Array.isArray(options)) {
options.forEach(option => {
FTableDOMHelper.create('option', {
attributes: {
value: option.Value || option.value || option
},
text: option.DisplayText || option.text || option,
parent: datalist
});
});
} else if (typeof options === 'object') {
Object.entries(options).forEach(([key, text]) => {
FTableDOMHelper.create('option', {
attributes: { value: key },
text: text,
parent: datalist
});
});
}
}
createHiddenInput(fieldName, field, value) {
const attributes = {
type: 'hidden',
name: fieldName,
id: `Edit-${fieldName}`,
value: value || ''
};
// Apply inputAttributes
if (field.inputAttributes) {
const parsed = this.parseInputAttributes(field.inputAttributes);
Object.assign(attributes, parsed);
}
return FTableDOMHelper.create('input', { attributes });
}
createTextarea(fieldName, field, value) {
const attributes = {
name: fieldName,
id: `Edit-${fieldName}`,
class: field.inputClass || '',
placeholder: field.placeholder || ''
};
// Apply inputAttributes
if (field.inputAttributes) {
const parsed = this.parseInputAttributes(field.inputAttributes);
Object.assign(attributes, parsed);
}
const textarea = FTableDOMHelper.create('textarea', { attributes });
textarea.value = value || '';
return textarea;
}
createSelect(fieldName, field, value) {
const attributes = {
name: fieldName,
id: `Edit-${fieldName}`,
class: field.inputClass || ''
};
// Apply inputAttributes
if (field.inputAttributes) {
const parsed = this.parseInputAttributes(field.inputAttributes);
Object.assign(attributes, parsed);
}
const select = FTableDOMHelper.create('select', { attributes });
if (field.options) {
//const options = this.resolveOptions(field);
this.populateSelectOptions(select, field.options, value);
}
return select;
}
createRadioGroup(fieldName, field, value) {
const wrapper = FTableDOMHelper.create('div', {
className: 'ftable-radio-group'
});
if (field.options) {
const options = Array.isArray(field.options) ? field.options :
typeof field.options === 'object' ? Object.entries(field.options).map(([k, v]) => ({Value: k, DisplayText: v})) : [];
options.forEach((option, index) => {
const radioWrapper = FTableDOMHelper.create('div', {
className: 'ftable-radio-wrapper',
parent: wrapper
});
const radioId = `${fieldName}_${index}`;
const radioAttributes = {
type: 'radio',
name: fieldName,
id: radioId,
value: option.Value || option.value || option,
class: field.inputClass || ''
};
if (field.required && index === 0) radioAttributes.required = 'required';
if (field.disabled) radioAttributes.disabled = 'disabled';
// Apply inputAttributes
if (field.inputAttributes) {
const parsed = this.parseInputAttributes(field.inputAttributes);
Object.assign(attributes, parsed);
}
const radio = FTableDOMHelper.create('input', {
attributes: radioAttributes,
parent: radioWrapper
});
if (radioAttributes.value === value) {
radio.checked = true;
}
const label = FTableDOMHelper.create('label', {
attributes: { for: radioId },
text: option.DisplayText || option.text || option,
parent: radioWrapper
});
});
}
return wrapper;
}
createCheckbox(fieldName, field, value) {
const wrapper = FTableDOMHelper.create('div', {
className: 'ftable-yesno-check-wrapper'
});
const isChecked = [1, '1', true, 'true'].includes(value);
// Determine "Yes" and "No" labels
let dataNo = 'No';
let dataYes = 'Yes';
if (field.values && typeof field.values === 'object') {
if (field.values['0'] !== undefined) dataNo = field.values['0'];
if (field.values['1'] !== undefined) dataYes = field.values['1'];
}
// Create the checkbox
const checkbox = FTableDOMHelper.create('input', {
className: ['ftable-yesno-check-input', field.inputClass || ''].filter(Boolean).join(' '),
attributes: {
type: 'checkbox',
name: fieldName,
id: `Edit-${fieldName}`,
value: '1'
},
parent: wrapper
});
checkbox.checked = isChecked;
if (field.label) {
// Optional: Add a static form label (e.g., "Is Active?")
const label = FTableDOMHelper.create('label', {
className: 'ftable-yesno-check-fixedlabel',
attributes: {
for: `Edit-${fieldName}`,
},
text: field.label,
parent: wrapper
});
} else {
// Create the label with data attributes
const label = FTableDOMHelper.create('label', {
className: 'ftable-yesno-check-text',
attributes: {
for: `Edit-${fieldName}`,
'data-yes': dataYes,
'data-no': dataNo
},
parent: wrapper
});
}
return wrapper;
}
populateSelectOptions(select, options, selectedValue) {
select.innerHTML = ''; // Clear existing options
if (Array.isArray(options)) {
options.forEach(option => {
const value = option.Value !== undefined ? option.Value :
option.value !== undefined ? option.value :
option; // fallback for string
const optionElement = FTableDOMHelper.create('option', {
attributes: { value: value },
text: option.DisplayText || option.text || option,
parent: select
});
if (option.Data && typeof option.Data === 'object') {
Object.entries(option.Data).forEach(([key, dataValue]) => {
optionElement.setAttribute(`data-${key}`, dataValue);
});
}
if (optionElement.value == selectedValue) {
optionElement.selected = true;
}
});
} else if (typeof options === 'object') {
Object.entries(options).forEach(([key, text]) => {
const optionElement = FTableDOMHelper.create('option', {
attributes: { value: key },
text: text,
parent: select
});
if (key == selectedValue) {
optionElement.selected = true;
}
});
}
}
createFileInput(fieldName, field, value) {
const attributes = {
type: 'file',
id: `Edit-${fieldName}`,
class: field.inputClass || ''
};
// extra check for name and multiple
let name = fieldName;
// Apply inputAttributes from field definition
if (field.inputAttributes) {
let hasMultiple = false;
const parsed = this.parseInputAttributes(field.inputAttributes);
Object.assign(attributes, parsed);
hasMultiple = parsed.multiple !== undefined && parsed.multiple !== false;
if (hasMultiple) {
name = `${fieldName}[]`;
}
}
attributes.name = name;
return FTableDOMHelper.create('input', { attributes });
}
}
// Enhanced FTable class with search functionality
class FTable extends FTableEventEmitter {
constructor(element, options = {}) {
super();
this.element = typeof element === 'string' ?
document.querySelector(element) : element;
if (!this.element) {
return;
}
// Prevent double initialization
if (this.element.ftableInstance) {
//console.warn('FTable is already initialized on this element. Using that.');
return this.element.ftableInstance;
}
this.options = this.mergeOptions(options);
this.verifyOptions();
this.logger = new FTableLogger(this.options.logLevel);
this.userPrefs = new FTableUserPreferences('', this.options.saveUserPreferencesMethod);
this.formBuilder = new FTableFormBuilder(this.options, this);
this.state = {
records: [],
totalRecordCount: 0,
currentPage: 1,
isLoading: false,
selectedRecords: new Set(),
sorting: [],
searchQueries: {}, // Stores current search terms per field
};
this.elements = {};
this.modals = {};
this.searchTimeout = null; // For debouncing
this.lastSortEvent = null;
this._recalculatedOnce = false;
// store it on the DOM too, so people can access it
this.element.ftableInstance = this;
this.init();
}
mergeOptions(options) {
const defaults = {
tableId: undefined,
logLevel: FTableLogger.LOG_LEVELS.WARN,
actions: {},
fields: {},
forcePost: true,
animationsEnabled: true,
loadingAnimationDelay: 1000,
defaultDateLocale: '',
defaultDateFormat: 'Y-m-d',
saveUserPreferences: true,
saveUserPreferencesMethod: 'localStorage',
defaultSorting: '',
tableReset: false,
// Paging
paging: false,
pageList: 'normal',
pageSize: 10,
pageSizes: [10, 25, 50, 100],
gotoPageArea: 'combobox',
// Sorting
sorting: false,
multiSorting: false,
multiSortingCtrlKey: true,
// Selection
selecting: false,
multiselect: false,
// child tables
openChildAsAccordion: false,
// Toolbar search
toolbarsearch: false, // Enable/disable toolbar search row
toolbarreset: true, // Show reset button
searchDebounceMs: 300, // Debounce time for search input
// Caching
listCache: 30000, // or listCache: 30000 (duration in ms)
// Messages
messages: { ...FTABLE_DEFAULT_MESSAGES } // Safe copy
};
return this.deepMerge(defaults, options);
}
deepMerge(target, source) {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = this.deepMerge(result[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
verifyOptions() {
if (this.options.pageSize && !this.options.pageSizes.includes(this.options.pageSize)) {
this.options.pageSize = this.options.pageSizes[0];
}
}
// Public
static setMessages(customMessages) {
Object.assign(FTABLE_DEFAULT_MESSAGES, customMessages);
}
init() {
this.processFieldDefinitions();
this.createMainStructure();
this.setupFTableUserPreferences();
this.createTable();
this.createModals();
// Create paging UI if enabled
if (this.options.paging) {
this.createPagingUI();
}
// Start resolving in background
this.resolveAsyncFieldOptions().then(() => {
// re-render dynamic options rows — no server call
setTimeout(() => {
this.refreshDisplayValues();
}, 0);
}).catch(console.error);
this.bindEvents();
this.updateSortingHeaders();
this.renderSortingInfo();
// Add essential CSS if not already present
//this.addEssentialCSS();
// now make sure all tables have a % width
this.initColumnWidths();
}
initColumnWidths() {
const visibleFields = this.columnList.filter(fieldName => {
const field = this.options.fields[fieldName];
return field.visibility !== 'hidden' && field.visibility !== 'separator';
});
const count = visibleFields.length;
visibleFields.forEach(fieldName => {
const field = this.options.fields[fieldName];
// U