@liedekef/ftable
Version:
Modern, lightweight, jQuery-free CRUD table for dynamic AJAX-powered tables.
1,482 lines (1,262 loc) • 184 kB
JavaScript
(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 = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
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: '×',
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