UNPKG

@liedekef/ftable

Version:

Modern, lightweight, jQuery-free CRUD table for dynamic AJAX-powered tables.

1,515 lines (1,274 loc) 168 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.FTable = factory()); }(this, (function () { 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }; 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: '&times;', 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(fieldNam