UNPKG

@liedekef/ftable

Version:

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

1,482 lines (1,262 loc) 184 kB
(function (global) { 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?', yes: 'Yes', no: 'No', 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(); this.pendingRequests = new Map(); // Track ongoing requests } 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(); } } async getOrCreate(url, params, fetchFn) { const key = this.generateKey(url, params); // Return cached result if available const cached = this.cache.get(key); if (cached) return cached; // Check if same request is already in progress if (this.pendingRequests.has(key)) { // Wait for the existing request to complete return await this.pendingRequests.get(key); } // Create new request const requestPromise = (async () => { try { const result = await fetchFn(); this.cache.set(key, result); return result; } finally { // Clean up pending request tracking this.pendingRequests.delete(key); } })(); // Track this request this.pendingRequests.set(key, requestPromise); return await requestPromise; } 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.trace(); 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 PROPERTY_ATTRIBUTES = new Set([ 'value', 'checked', 'selected', 'disabled', 'readOnly', 'name', 'id', 'type', 'placeholder', 'min', 'max', 'step', 'required', 'multiple', 'accept', 'className', 'textContent', 'innerHTML', 'title' ]); static create(tag, options = {}) { const element = document.createElement(tag); // Handle special cases first if (options.style !== undefined) { element.style.cssText = options.style; } FTableDOMHelper.PROPERTY_ATTRIBUTES.forEach(prop => { if (prop in options && options[prop] !== null) { element[prop] = options[prop]; } }); if (options.parent !== undefined) { options.parent.appendChild(element); } // the attributes last, so we can override stuff if needed if (options.attributes) { Object.entries(options.attributes).forEach(([key, value]) => { if (value !== null) { // Use property if it exists on the element, otherwise use setAttribute if (FTableDOMHelper.PROPERTY_ATTRIBUTES.has(key)) { element[key] = value; } else { element.setAttribute(key, value); } } }); } 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', textContent: this.options.title, parent: this.modal }); // Close button const closeBtn = FTableDOMHelper.create('span', { className: 'ftable-modal-close', innerHTML: '&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 || ''}`, innerHTML: `<span>${button.text}</span>`, parent: footer }); if (button.onClick) { // Store original handler btn._originalOnClick = button.onClick; // Attach wrapped handler btn.addEventListener('click', this._createWrappedClickHandler(btn)); } }); } // Close on overlay click if (this.options.closeOnOverlayClick) { 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; // Enable all ftable-dialog-button buttons const buttons = this.modal.querySelectorAll('.ftable-dialog-button'); buttons.forEach(btn => { btn.disabled = false; }); 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); } } _createWrappedClickHandler(buttonElement) { return async (event) => { // Disable immediately buttonElement.disabled = true; try { const handler = buttonElement._originalOnClick; if (typeof handler === 'function') { const result = handler.call(buttonElement, event); if (result instanceof Promise) { await result; } } } catch (error) { console.error('Modal button action failed:', error); } finally { // Re-enable regardless of outcome buttonElement.disabled = false; } }; } } 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 this.resolvedFieldOptions = new Map(); // Store resolved options per context // Initialize with empty cache objects Object.keys(this.options.fields || {}).forEach(fieldName => { this.resolvedFieldOptions.set(fieldName, {}); }); Object.entries(this.options.fields).forEach(([fieldName, field]) => { this.originalFieldOptions.set(fieldName, field.options); }); } // Get options for specific context async getFieldOptions(fieldName, context = 'table', params = {}) { const field = this.options.fields[fieldName]; const originalOptions = this.originalFieldOptions.get(fieldName); // If no options or already resolved for this context with same params, return cached if (!originalOptions) { return null; } // Determine if we should skip caching for this specific context const shouldSkipCache = this.shouldForceRefreshForContext(field, context, params); const cacheKey = this.generateOptionsCacheKey(context, params); // Skip cache if configured or forceRefresh requested if (!shouldSkipCache && !params.forceRefresh) { const cached = this.resolvedFieldOptions.get(fieldName)[cacheKey]; if (cached) return cached; } try { // Create temp field with original options for resolution const tempField = { ...field, options: originalOptions }; const resolved = await this.resolveOptions(tempField, { ...params }, context, shouldSkipCache); // we store the resolved option always this.resolvedFieldOptions.get(fieldName)[cacheKey] = resolved; return resolved; } catch (err) { console.error(`Failed to resolve options for ${fieldName} (${context}):`, err); return originalOptions; } } /** * Clear resolved options for specific field or all fields * @param {string|null} fieldName - Field name to clear, or null for all fields * @param {string|null} context - Context to clear ('table', 'create', 'edit'), or null for all contexts */ clearResolvedOptions(fieldName = null, context = null) { if (fieldName) { // Clear specific field if (this.resolvedFieldOptions.has(fieldName)) { if (context) { // Clear specific context for specific field this.resolvedFieldOptions.get(fieldName)[context] = null; } else { // Clear all contexts for specific field this.resolvedFieldOptions.set(fieldName, { table: null, create: null, edit: null }); } } } else { // Clear all fields if (context) { // Clear specific context for all fields this.resolvedFieldOptions.forEach((value, key) => { this.resolvedFieldOptions.get(key)[context] = null; }); } else { // Clear everything this.resolvedFieldOptions.forEach((value, key) => { this.resolvedFieldOptions.set(key, { table: null, create: null, edit: null }); }); } } } // Helper method to determine caching behavior shouldForceRefreshForContext(field, context, params) { // Rename to reflect what it actually does now if (!field.noCache) return false; if (typeof field.noCache === 'boolean') return field.noCache; if (typeof field.noCache === 'function') return field.noCache({ context, ...params }); if (typeof field.noCache === 'object') return field.noCache[context] === true; return false; } generateOptionsCacheKey(context, params) { // Create a unique key based on context and dependency values const keyParts = [context]; if (params.dependedValues) { // Include relevant dependency values in the cache key Object.keys(params.dependedValues).sort().forEach(key => { keyParts.push(`${key}=${params.dependedValues[key]}`); }); } return keyParts.join('|'); } 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) { // in this function, field.options already contains the resolved values const container = FTableDOMHelper.create('div', { className: (field.type != 'hidden' ? 'ftable-input-field-container' : ''), attributes: { id: `ftable-input-field-container-div-${fieldName}`, } }); if (field.type != 'hidden') { // Label const label = FTableDOMHelper.create('div', { className: 'ftable-input-label', textContent: field.inputTitle || field.title, parent: container }); } // Input const inputContainer = this.createInput(fieldName, field, record[fieldName], formType); container.appendChild(inputContainer); return container; } async createForm(formType = 'create', record = {}) { this.currentFormRecord = record; const form = FTableDOMHelper.create('form', { className: `ftable-dialog-form ftable-${formType}-form` }); // Build dependency map first this.buildDependencyMap(); // Create form fields using for...of instead of forEach, this allows the await to work for (const [fieldName, field] of Object.entries(this.options.fields)) { if (this.shouldIncludeField(field, formType)) { let fieldWithOptions = { ...field }; if (!field.dependsOn) { const contextOptions = await this.getFieldOptions(fieldName, formType, { record, source: formType }); fieldWithOptions.options = contextOptions; } else { // For dependent fields, use placeholder or original options // They will be resolved when dependencies change fieldWithOptions.options = field.options; } const fieldContainer = this.createFieldContainer(fieldName, fieldWithOptions, record, formType); form.appendChild(fieldContainer); } } // Set up dependency listeners after all fields are created this.setupDependencyListeners(form); return form; } shouldResolveOptions(options) { return options && (typeof options === 'function' || typeof options === 'string') && !Array.isArray(options) && !(typeof options === 'object' && !Array.isArray(options) && Object.keys(options).length > 0); } 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 = {}, source = '', noCache = false) { 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; // Enhance params with clearCache() method const enhancedParams = { ...params, source: source, clearCache: () => { noCache = true; // Also update the field's noCache setting for future calls this.updateFieldCacheSetting(field, source, 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) { try { const response = this.options.forcePost ? await FTableHttpClient.post(url) : await FTableHttpClient.get(url); return response.Options || response.options || response || []; } catch (error) { console.error(`Failed to load options from ${url}:`, error); return []; } } else { // Use getOrCreate to prevent duplicate requests return await this.optionsCache.getOrCreate(url, {}, async () => { try { const response = this.options.forcePost ? await FTableHttpClient.post(url) : await FTableHttpClient.get(url); return response.Options || response.options || response || []; } catch (error) { console.error(`Failed to load options from ${url}:`, error); return []; } }); } } updateFieldCacheSetting(field, context, skipCache) { if (!field.noCache) { // Initialize noCache as object for this context field.noCache = { [context]: skipCache }; } else if (typeof field.noCache === 'boolean') { // Convert boolean to object, preserving existing behavior for other contexts field.noCache = { 'table': field.noCache, 'create': field.noCache, 'edit': field.noCache, [context]: skipCache // Override for this context }; } else if (typeof field.noCache === 'object') { // Update specific context field.noCache[context] = skipCache; } // Function-based noCache remains unchanged (runtime decision) } clearOptionsCache(url = null, params = null) { this.optionsCache.clear(url, params); } getFormValues(form) { const values = {}; // Get all form elements const elements = form.elements; for (let i = 0; i < elements.length; i++) { const element = elements[i]; const name = element.name; if (!name || element.disabled) continue; switch (element.type) { case 'checkbox': values[name] = element.checked ? element.value || '1' : '0'; break; case 'radio': if (element.checked) { values[name] = element.value; } break; case 'select-multiple': values[name] = Array.from(element.selectedOptions).map(option => option.value); break; default: values[name] = element.value; break; } } return values; } async handleDependencyChange(form, changedFieldname = '') { // Build dependedValues: { field1: value1, field2: value2 } const dependedValues = this.getFormValues(form); const formType = form.classList.contains('ftable-create-form') ? 'create' : 'edit'; const record = this.currentFormRecord || {}; const baseParams = { record, source: formType, form, dependedValues }; 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 = ''; } // Get current field value BEFORE resolving new options const currentValue = input.value || record[fieldName] || ''; // Resolve options with current context const params = { ...baseParams, dependsOnField: field.dependsOn, dependsOnValue: dependedValues[field.dependsOn] }; const newOptions = await this.getFieldOptions(fieldName, formType, params); // Populate the input if (input.tagName === 'SELECT') { this.populateSelectOptions(input, newOptions, currentValue); } else if (input.tagName === 'INPUT' && input.list) { this.populateDatalistOptions(input.list, newOptions); // For datalist, set the value directly if (currentValue) input.value = currentValue; } setTimeout(() => { input.dispatchEvent(new Event('change', { bubbles: true })); }, 0); } 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 === undefined) { value = null; } if (value === null && field.defaultValue ) { 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': 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); if (input.datalistElement && input.datalistElement instanceof Node) { container.appendChild(input.datalistElement); } } } else { // No custom input function — just add the default input container.appendChild(input); if (input.datalistElement && input.datalistElement instanceof Node) { container.appendChild(input.datalistElement); } } // Add explanation if provided if (field.explain) { const explain = FTableDOMHelper.create('div', { className: 'ftable-field-explain', innerHTML: `<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', { id: 'real-' + fieldName, type: 'hidden', value: value, name: fieldName }); // Create visible input const attributes = { 'data-date': value }; // Set any additional attributes if (field.inputAttributes) { const parsed = this.parseInputAttributes(field.inputAttributes); Object.assign(attributes, parsed); } const visibleInput = FTableDOMHelper.create('input', { attributes: attributes, id: `Edit-${fieldName}`, type: 'text', placeholder: field.placeholder || null, className: field.inputClass || 'datepicker-input', readOnly: true }); // Append both inputs container.appendChild(hiddenInput); container.appendChild(visibleInput); // Apply FDatepicker // Initialize FDatepicker AFTER the container is in the DOM // We'll use a small timeout to ensure DOM attachment switch (field.type) { case 'date': setTimeout(() => { const picker = new FDatepicker(visibleInput, { format: dateFormat, altField: 'real-' + fieldName, altFormat: 'Y-m-d' }); }, 0); break; case 'datetime': case 'datetime-local': setTimeout(() => { const picker = new FDatepicker(visibleInput, { format: dateFormat, timepicker: true, altField: 'real-' + fieldName, altFormat: 'Y-m-d H:i:00' }); }, 0); break; } return container; } else { return createTypedInput(fieldName, field, value); } } createTypedInput(fieldName, field, value) { const inputType = field.type || 'text'; const attributes = { }; // 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}[]`; } } const input = FTableDOMHelper.create('input', { attributes: attributes, type: inputType, id: `Edit-${fieldName}`, className: field.inputClass || null, placeholder: field.placeholder || null, value: value, name: name }); // 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 attributes = { list: `${fieldName}-datalist` }; // Apply inputAttributes if (field.inputAttributes) { const parsed = this.parseInputAttributes(field.inputAttributes); Object.assign(attributes, parsed); } const input = FTableDOMHelper.create('input', { attributes: attributes, type: 'search', name: fieldName, id: `Edit-${fieldName}`, className: field.inputClass || null, placeholder: field.placeholder || null, value: value }); // Create the datalist element const datalist = FTableDOMHelper.create('datalist', { id: `${fieldName}-datalist` }); // Populate datalist options if (field.options) { this.populateDatalistOptions(datalist, field.options); } // Store reference input.datalistElement = datalist; return input; } populateDatalistOptions(datalist, options) { datalist.innerHTML = ''; // Clear existing options if (Array.isArray(options)) { options.forEach(option => { FTableDOMHelper.create('option', { value: option.Value || option.value || option, textContent: option.DisplayText || option.text || option, parent: datalist }); }); } else if (typeof options === 'object') { Object.entries(options).forEach(([key, text]) => { FTableDOMHelper.create('option', { value: key, textContent: text, parent: datalist }); }); } } createHiddenInput(fieldName, field, value) { const attributes = { }; // Apply inputAttributes if (field.inputAttributes) { const parsed = this.parseInputAttributes(field.inputAttributes); Object.assign(attributes, parsed); } return FTableDOMHelper.create('input', { attributes: attributes, type: 'hidden', name: fieldName, id: `Edit-${fieldName}`, value: value }); } createTextarea(fieldName, field, value) { const attributes = { }; // Apply inputAttributes if (field.inputAttributes) { const parsed = this.parseInputAttributes(field.inputAttributes); Object.assign(attributes, parsed); } return FTableDOMHelper.create('textarea', { attributes: attributes, name: fieldName, id: `Edit-${fieldName}`, className: field.inputClass || null, placeholder: field.placeholder || null, value: value }); } createSelect(fieldName, field, value) { const attributes = { }; // 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 select = FTableDOMHelper.create('select', { attributes: attributes, name: fieldName, id: `Edit-${fieldName}`, className: field.inputClass || null }); if (field.options) { 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 = { }; // Apply inputAttributes if (field.inputAttributes) { const parsed = this.parseInputAttributes(field.inputAttributes); Object.assign(radioAttributes, parsed); } const fieldValue = option.Value !== undefined ? option.Value : option.value !== undefined ? option.value : option; // fallback for string const radio = FTableDOMHelper.create('input', { attributes: radioAttributes, type: 'radio', name: fieldName, id: radioId, value: fieldValue, className: field.inputClass || null, checked: fieldValue == value, parent: radioWrapper }); const label = FTableDOMHelper.create('label', { attributes: { for: radioId }, textContent: 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 = this.options.messages.no; let dataYes = this.options.messages.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(' '), type: 'checkbox', name: fieldName, id: `Edit-${fieldName}`, value: '1', parent: wrapper }); checkb