class FormFields {
constructor() {
this.forms = new Map(); // Store form configurations
this.initialized = false;
this.stepMultiplier = 1;
this.cache = window.jvbCache;
this.queue = window.jvbQueue;
this.debouncer = window.debouncer;
this.activeOperations = new Map(); // operationId -> operation details
this.pendingForms = new Map(); // formId -> pending operation data
this.activeRepeaters = new Map();
this.repeaterDelays = {
change: 6000,
typing: 3000,
blur: 1500,
add: 500,
remove: 800,
reorder: 1000
};
this.ignore = [];
// Bind methods for event delegation
this.submitHandler = this.handleSubmit.bind(this);
this.changeHandler = this.handleChange.bind(this);
this.clickHandler = this.handleClick.bind(this);
this.focusHandler = this.handleFocus.bind(this);
this.keyHandler = this.handleKeys.bind(this);
this.blurHandler = this.handleBlur.bind(this);
this.init();
}
/**
* Initialize the form manager
*/
async init() {
this.scanExistingForms();
this.initListeners();
await this.resumePendingOperations();
}
/**
* Scan page for existing forms and register them automatically
*/
scanExistingForms() {
//look for forms with the save endpoint
const managedForms = document.querySelectorAll('form[data-form-id]');
managedForms.forEach(form => {
try {
this.registerForm(form);
} catch (error) {
this.handleError('Failed to register form:', form, error);
}
});
}
/**
* Register a form with configuration from data attributes
* @param {HTMLFormElement} formElement
* @param {Object} options - Override options (optional)
* @returns {Object} Form configuration
*/
registerForm(formElement, options = {}) {
options = {
...this.parseFormDataAttributes(formElement),
...options
};
// Use existing addForm method with parsed config
return this.addForm(formElement, options);
}
/**
* Parse form configuration from data attributes
* @param {HTMLFormElement} formElement
* @returns {Object} Configuration object
*/
parseFormDataAttributes(formElement) {
const dataset = formElement.dataset;
const config = {};
// Basic options
if ('autoSave' in dataset) {
config.autoSave = dataset.autoSave !== 'false';
}
if ('noCache' in dataset) {
config.noCache = true;
}
if ('saveDelay' in dataset) {
config.saveDelay = parseInt(dataset.saveDelay) || 2000;
}
if ('save' in dataset) {
config.api = dataset.save;
}
if ('itemId' in dataset) {
config.itemID = dataset.itemId;
}
if ('content' in dataset) {
config.content = dataset.content;
}
if ('title' in dataset) {
config.title = dataset.title;
}
// Advanced options
if ('noAutosave' in dataset) {
config.autoSave = false;
}
if ('onChange' in dataset) {
// Look for global callback function
const callbackName = dataset.onChange;
if (window[callbackName] && typeof window[callbackName] === 'function') {
config.onChange = window[callbackName];
}
}
if ('onSave' in dataset) {
const callbackName = dataset.onSave;
if (window[callbackName] && typeof window[callbackName] === 'function') {
config.onSave = window[callbackName];
}
}
if ('onSubmit' in dataset) {
const callbackName = dataset.onSubmit;
if (window[callbackName] && typeof window[callbackName] === 'function') {
config.onSubmit = window[callbackName];
}
}
// Headers
config.headers = { 'action_nonce': jvbSettings?.dash };
if ('headers' in dataset) {
try {
const additionalHeaders = JSON.parse(dataset.headers);
config.headers = { ...config.headers, ...additionalHeaders };
} catch (e) {
console.warn('Invalid headers JSON in data-headers:', dataset.headers);
}
}
return config;
}
/**
* Add a form to be managed by this instance
* @param {HTMLFormElement} formElement
* @param {Object} options
* @returns {Object} Form configuration
*/
addForm(formElement, options = {}) {
const formConfig = {
element: formElement,
id: formElement.dataset.formId,
data: this.collectFormData(formElement),
initialized: false,
// Default options with overrides
options: {
onSave: false,
onChange: null,
onSubmit: null,
itemID: null,
content: null,
saveDelay: 2000,
autoSave: true,
api: formElement.dataset.save || null,
headers: { 'action_nonce': jvbSettings?.dash },
...options
},
// Form-specific state
state: {
saveTimeout: null,
lastData: null,
isDirty: false,
operationId: null,
cached: false
},
dependencies: new Map(),
};
// Store the configuration
this.forms.set(formConfig.id, formConfig);
// Initialize form-specific features
this.initFormFeatures(formConfig);
return formConfig;
}
initFormFeatures(formConfig) {
if (formConfig.initialized) {
return;
}
formConfig.initialized = true;
const form = formConfig.element;
// Only initialize what exists
const featureChecks = [
{ selector: '[data-tab]', init: () => this.initTabs(formConfig) },
{ selector: '.repeater', init: () => this.initRepeaterFields(form) },
{ selector: '[data-editor="true"]', init: () => this.initQuillEditor(formConfig) },
{ selector: '.gallery', init: () => this.initGalleryFields(formConfig) },
{ selector: '.image', init: () => this.initImageFields(formConfig) },
{ selector: '[data-limit]', init: () => this.initCharacterLimits(formConfig) },
{ selector: '[data-depends-on]', init: () => this.initConditionalFields(formConfig)}
];
requestAnimationFrame(() => {
featureChecks.forEach(({selector, init} ) => {
if (form.querySelector(selector)) {
init();
}
});
});
}
initTabs(formConfig) {
if (!window.jvbTabs) {
console.warn('jvbTabs not available');
return;
}
let form = formConfig.element;
// Check if form is within a tabs container
const tabsContainer = form.closest('[data-tab]') ||
form.closest('.tabs-container') ||
form.querySelector('.tabs:not(.icon)');
if (tabsContainer) {
// Initialize tabs with form-specific callbacks
formConfig.tabs = new window.jvbTabs(form, {
updateURL: false, // Disable URL updates for form tabs,
});
this.addTabNavigationButtons(formConfig);
}
}
addTabNavigationButtons(formConfig) {
const form = formConfig.element;
const sections = form.querySelectorAll('section[id]');
if (sections.length <= 1) {
return; // No need for navigation if only one section
}
sections.forEach((section, index) => {
let isLast = index === sections.length - 1;
// Check if navigation buttons already exist
if (section.querySelector('.tab-navigation')) {
return;
}
let buttons = {
previous: sections[index - 1]?.id ?? null,
next: (isLast) ? null : sections[index + 1]?.id ?? null
};
for (var [direction, id] of Object.entries(buttons)) {
if (id){
let button = form.querySelector('button[type=submit]').cloneNode(true);
button.type = 'button';
button.className = `tab-navigation ${direction}`;
button.innerText = window.uppercaseFirst(direction);
let icon = (direction === 'previous') ? 'left' : 'right';
button.prepend(window.getIcon(icon));
button.dataset.navigateTo = id;
if (isLast) {
section.querySelector('.form-actions').prepend(button);
} else {
section.append(button);
}
}
}
});
}
/*********************************************************
*
* CACHE
*
*********************************************************/
/**
* Cache form data with operation tracking
*/
async cacheFormData(formConfig) {
if (!this.cache || !formConfig.options.cache) return false;
try {
const cacheData = {
formId: formConfig.id,
formData: this.collectFormData(formConfig.element),
formConfig: {
endpoint: formConfig.element.dataset.save,
content: formConfig.options.content,
itemID: formConfig.options.itemID,
headers: formConfig.options.headers
},
timestamp: Date.now(),
status: 'pending',
operationId: formConfig.state.operationId
};
const cacheKey = `form_${formConfig.id}`;
await this.cache.setItem(cacheKey, cacheData);
return true;
} catch (error) {
this.handleError('Failed to cache form data:', error);
return false;
}
}
/**
* Update cached form with operation ID
*/
async updateCachedFormOperation(formId, operationId) {
if (!this.cache || this.forms.get(formId).options.noCache) return;
try {
const cacheKey = `form_${formId}`;
const cachedData = await this.cache.getItem(cacheKey);
if (cachedData) {
cachedData.operationId = operationId;
cachedData.status = 'queued';
await this.cache.setItem(cacheKey, cachedData);
}
} catch (error) {
this.handleError('Failed to update cached form operation:', error);
}
}
/**
* Resume pending operations from cache
*/
async resumePendingOperations() {
if (!this.cache) return;
try {
// Get all cached form operations for current page
const pendingForms = await this.getCachedFormsForCurrentPage();
if (pendingForms.length > 0) {
for (const cachedForm of pendingForms) {
await this.resumeFormOperation(cachedForm);
}
}
} catch (error) {
this.handleError('Failed to resume pending operations:', error);
}
}
/**
* Get cached forms for current page
*/
async getCachedFormsForCurrentPage() {
if (!this.cache) return [];
try {
const pendingForms = [];
for (let [key, value] of this.forms) {
if (!value.options.cache){
continue;
}
const cachedData = await this.cache.getItem(`form_${key}`);
if (cachedData &&
cachedData.operationId &&
['queued', 'pending', 'processing'].includes(cachedData.status)) {
pendingForms.push(cachedData);
}
}
return pendingForms;
} catch (error) {
this.handleError('Failed to get cached forms:', error);
return [];
}
}
/**
* Resume a specific form operation
*/
async resumeFormOperation(cachedForm) {
try {
// Check if operation still exists in queue
const operationStatus = await this.queue.getOperationStatus(cachedForm.operationId);
if (operationStatus) {
// Re-track the operation
this.activeOperations.set(cachedForm.operationId, {
formId: cachedForm.formId,
status: operationStatus.status,
startTime: cachedForm.timestamp,
data: cachedForm.formConfig
});
// Update form config if form is registered
const formConfig = this.forms.get(cachedForm.formId);
if (formConfig) {
formConfig.state.operationId = cachedForm.operationId;
formConfig.state.cached = true;
// Show resumption notification
this.showFormResumptionNotification(formConfig, operationStatus.status);
}
// Set up completion handler
this.queue.onOperationComplete(cachedForm.operationId, (operation) => {
this.handleOperationComplete(operation);
});
} else {
// Operation no longer exists, clean up cache
await this.removeCachedForm(cachedForm.formId);
}
} catch (error) {
this.handleError('Failed to resume form operation:', error);
// Clean up on error
await this.removeCachedForm(cachedForm.formId);
}
}
/**
* Show form resumption notification
*/
showFormResumptionNotification(formConfig, status) {
const form = formConfig.element;
const notification = document.createElement('div');
notification.className = 'form-resumption-notification';
notification.innerHTML = `
🔄
Resumed: ${this.getStatusMessage(status)}
`;
// Insert notification
form.insertBefore(notification, form.firstChild);
// Auto-dismiss
setTimeout(() => {
notification.remove();
}, 5000);
// Manual dismiss
notification.querySelector('.dismiss').addEventListener('click', () => {
notification.remove();
});
}
/**
* Remove cached form data
*/
async removeCachedForm(formId) {
if (!this.cache || !this.forms.get(formId).options.cache) return;
try {
const cacheKey = `form_${formId}`;
await this.cache.removeItem(cacheKey);
} catch (error) {
this.handleError('Failed to remove cached form:', error);
}
}
/**
* Get form cache status
*/
async getFormCacheStatus(formId) {
if (!this.cache) return null;
try {
const cacheKey = `form_${formId}`;
const cachedData = await this.cache.getItem(cacheKey);
return cachedData ? {
formId: cachedData.formId,
status: cachedData.status,
operationId: cachedData.operationId,
timestamp: cachedData.timestamp,
hasData: !!cachedData.formData
} : null;
} catch (error) {
this.handleError('Failed to get form cache status:', error);
return null;
}
}
/**
* Track form operation
*/
trackOperation(formId, operationId, operationData) {
this.activeOperations.set(operationId, {
formId: formId,
status: 'queued',
startTime: Date.now(),
data: operationData
});
// Update form config
const formConfig = this.forms.get(formId);
if (formConfig) {
formConfig.state.operationId = operationId;
formConfig.state.cached = true;
}
}
/**
* Handle operation updates from queue
*/
handleOperationUpdate(operation) {
const activeOp = this.activeOperations.get(operation.id);
if (!activeOp) return;
// Update operation status
activeOp.status = operation.status;
this.activeOperations.set(operation.id, activeOp);
// Update form UI if needed
const formConfig = this.forms.get(activeOp.formId);
if (formConfig) {
// Show progress or status updates in form UI
this.updateFormStatus(formConfig, operation.status);
}
}
/**
* Handle operation completion
*/
async handleOperationComplete(operation) {
const activeOp = this.activeOperations.get(operation.id);
if (!activeOp) return;
const formId = activeOp.formId;
const formConfig = this.forms.get(formId);
if (formConfig) {
// Clear operation tracking
formConfig.state.operationId = null;
formConfig.state.cached = false;
formConfig.state.isDirty = false;
// Update last saved data
formConfig.data = this.collectFormData(formConfig.element);
// Update form UI
this.updateFormStatus(formConfig, 'completed');
}
// Clean up
this.activeOperations.delete(operation.id);
await this.removeCachedForm(formId);
}
/**
* Update form status in UI
*/
updateFormStatus(formConfig, status) {
// Add status indicator to form or show notification
const statusElement = formConfig.element.querySelector('.form-status');
if (statusElement) {
statusElement.className = `form-status ${status}`;
statusElement.textContent = this.getStatusMessage(status);
}
}
/**
* Get human-readable status message
*/
getStatusMessage(status) {
const messages = {
'queued': 'Queued for saving...',
'pending': 'Waiting to save...',
'processing': 'Saving...',
'completed': 'Saved successfully',
'failed': 'Save failed'
};
return messages[status] || status;
}
/*********************************************************
*
* EVENT HANDLERS
*
*********************************************************/
handleSubmit(event) {
const form = event.target.closest('form');
if (!form || !this.forms.has(form.dataset.formId)) return;
this.checkHiddenTabs(this.forms.get(form.dataset.formId));
event.preventDefault();
const formConfig = this.forms.get(form.dataset.formId);
// Call form-specific submit callback if provided
if (formConfig.options.onSubmit) {
formConfig.options.onSubmit(event, this.collectFormData(formConfig.element));
} else {
// Default submit behavior
this.handleSave(this.collectFormData(formConfig.element), formConfig);
}
}
checkHiddenTabs(formConfig) {
if (!('tabs' in formConfig)) {
return;
}
let sections = formConfig.element.querySelectorAll('section');
for (let section of sections) {
let requiredInputs = section.querySelector('[required]');
if (requiredInputs) {
formConfig.tabs.switchTab(section.id);
return;
}
}
}
handleChange(event) {
// Cache target checks
const target = event.target;
const form = target.form || target.closest('form');
console.log('[form]Handling change');
// Early return if not a managed form
if (!form?.dataset.formId || !this.forms.has(form.dataset.formId)) return;
console.log('[form]Managing this form');
// Image fields handled separately
if (window.targetCheck(event, '.image.field')) {
return;
}
console.log('[form] Not image field');
const formConfig = this.forms.get(form.dataset.formId);
const fieldName = event.target.name;
// Check conditionals immediately for all fields
this.checkConditionals(fieldName, formConfig);
console.log('[form]Conditionals checked');
// Handle special field types
this.handleSpecialFields(event, formConfig);
// Skip autosave if disabled
if ('noautosave' in form.dataset) {
console.log('No Autosave set');
return;
}
if (fieldName.includes('::')) {
const groupName = fieldName.split('::')[0];
this.scheduleSave(formConfig, {
type: 'field',
fieldName: groupName,
reason: event.type,
delay: 3000
});
} else if (fieldName.includes(':')) {
const row = target.closest('.repeater-row');
if (row) {
const repeater = row.closest('.field.repeater');
const repeaterName = repeater.dataset.field;
const delay = this.getRepeaterChangeDelay(event.target, event.type);
this.scheduleSave(formConfig, {
type: 'field',
fieldName: repeaterName,
reason: event.type,
delay: delay
});
}
} else {
// Schedule form-wide save
this.scheduleSave(formConfig, {
type: 'form',
reason: 'change'
});
}
}
handleClick(event) {
const form = event.target.closest('form');
if (!form || !this.forms.has(form.dataset.formId)) return;
const formConfig = this.forms.get(form.dataset.formId);
if (window.targetCheck(event, '.add-repeater-row')) {
const repeater = event.target.closest('.repeater');
this.addRepeaterRow(repeater, formConfig);
// Schedule save after add
this.scheduleSave(formConfig, {
type: 'field',
fieldName: repeater.dataset.field,
reason: 'add',
delay: this.repeaterDelays.add
});
} else if (window.targetCheck(event, '.remove-row')) {
const repeater = event.target.closest('.repeater');
this.removeRepeaterRow(event.target, formConfig);
// Schedule save after remove
this.scheduleSave(formConfig, {
type: 'field',
fieldName: repeater.dataset.field,
reason: 'remove',
delay: this.repeaterDelays.remove
});
} else if (window.targetCheck(event, '.remove-image')) {
this.handleImageRemove(event.target.closest('.field'));
} else if (window.targetCheck(event, '.replace-image')) {
let fileInput = event.closest('.image').querySelector('input[type="file"]');
fileInput.click();
} else if (window.targetCheck(event, 'div.quantity')) {
let quantity = window.targetCheck(event, 'div.quantity');
this.handleNumberClick(event, quantity);
} else if (window.targetCheck(event, '.tab-navigation')) {
formConfig.tabs.switchTab(event.target.dataset.navigateTo);
}
}
handleFocus(event) {
const form = event.target.closest('form');
if (!form || !this.forms.has(form.dataset.formId)) return;
const repeaterRow = event.target.closest('.repeater-row');
if (!repeaterRow) return;
const formConfig = this.forms.get(form.dataset.formId);
const rowId = repeaterRow.id || this.generateRowId(repeaterRow);
// Store the currently active repeater field
this.activeRepeaters.set(form.dataset.formId, {
rowId: rowId,
element: event.target,
formConfig: formConfig
});
}
handleBlur(event) {
const form = event.target.closest('form');
if (!form || !this.forms.has(form.dataset.formId)) return;
// Handle repeater field blur with shorter delay
const repeaterRow = event.target.closest('.repeater-row');
if (repeaterRow) {
const repeater = repeaterRow.closest('.field.repeater');
this.scheduleSave(this.forms.get(form.dataset.formId), {
type: 'field',
fieldName: repeater.dataset.field,
reason: 'blur',
delay: this.repeaterDelays.blur
});
}
}
handleKeys(e) {
const MAX_MULTIPLIER = 10000;
if (e.ctrlKey && e.shiftKey) {
this.stepMultiplier = Math.min(
Math.max(this.stepMultiplier * 100, 1000),
MAX_MULTIPLIER
);
} else if (e.shiftKey) {
this.stepMultiplier = Math.min(
Math.max(this.stepMultiplier * 10, 100),
MAX_MULTIPLIER
);
} else if (e.key === 'Escape') {
this.stepMultiplier = 1;
}
}
/**
* Initialize global event listeners (single delegation point)
*/
initListeners() {
if (this.initialized) return;
// Use event delegation for all forms
document.addEventListener('submit', this.submitHandler);
document.addEventListener('change', this.changeHandler);
document.addEventListener('click', this.clickHandler);
document.addEventListener('keydown', this.keyHandler);
document.addEventListener('focusin', this.focusHandler);
document.addEventListener('focusout', this.blurHandler);
this.initialized = true;
}
removeChangeListener() {
document.removeEventListener('change', this.changeHandler);
}
addChangeListener() {
document.addEventListener('change', this.changeHandler);
}
/*******************************************************************
*
* Data Processing
*
*******************************************************************/
// collectFormData(form) {
// const formData = new FormData(form);
// let data = {};
// //push any fields that need all parts to a 'sendAll' property
// let repeaterData = {};
//
// for (let [key, value] of formData.entries()) {
// if (this.ignore.includes(key) || key.endsWith('_temp')) {
// continue;
// }
//
// let post = null;
// let original = null;
//
// //If we're on a table view, we need to organize this by post ID
// if (key.includes('|')) {
// [post, key] = key.split('|');
// //Temporarily store data to merge later
// original = data;
// data = original[post] ?? {};
// }
//
// // Check if this is a repeater field (3 parts: field:index:name)
// const keyParts = key.split(':');
// if (keyParts.length === 3) {
// // Handle repeater fields (field:index:name)
// let [fieldName, index, rawSubField] = keyParts;
//
// // Handle array fields (remove [] brackets)
// const isArrayField = rawSubField.endsWith('[]');
// const subField = rawSubField.replace('[]', '');
//
// // Initialize repeater structure
// if (!repeaterData[fieldName]) {
// repeaterData[fieldName] = {};
// }
// if (!repeaterData[fieldName][index]) {
// repeaterData[fieldName][index] = {};
// }
//
// // Handle different field types for repeater
// let fieldValue = value;
//
// // Only check radio buttons since FormData handles checkboxes correctly
// const fieldElement = form.querySelector(`[name="${key}"]`);
// if (fieldElement && fieldElement.type === 'radio' && !fieldElement.checked) {
// continue; // Skip unchecked radios
// }
//
// // Handle array fields (like checkbox groups)
// if (isArrayField || repeaterData[fieldName][index][subField]) {
// // Initialize as array if not already
// if (!repeaterData[fieldName][index][subField]) {
// repeaterData[fieldName][index][subField] = [];
// } else if (!Array.isArray(repeaterData[fieldName][index][subField])) {
// repeaterData[fieldName][index][subField] = [repeaterData[fieldName][index][subField]];
// }
// repeaterData[fieldName][index][subField].push(fieldValue);
// } else {
// // Single value field
// repeaterData[fieldName][index][subField] = fieldValue;
// }
//
// } else if (keyParts.length === 2) {
// // Handle group fields (group:field)
// let [groupName, fieldName] = keyParts;
//
// // Initialize group structure if needed
// if (!data[groupName]) {
// data[groupName] = {};
// }
//
// // Handle array values for group fields (checkboxes, etc.)
// if (data[groupName][fieldName]) {
// if (!Array.isArray(data[groupName][fieldName])) {
// data[groupName][fieldName] = [data[groupName][fieldName]];
// }
// data[groupName][fieldName].push(value);
// } else {
// data[groupName][fieldName] = value;
// }
//
// } else if (key.includes('[')) {
// let [fieldKey, v ] = key.split('[');
// v = v.replace(']','');
// if (!Object.hasOwn(data, fieldKey)) {
// data[fieldKey] = {};
//
// if (!Object.hasOwn(data, 'sendAll')) {
// data['sendAll'] = [fieldKey];
// } else if (!data['sendAll'].includes(fieldKey)) {
// data['sendAll'].push(fieldKey);
// }
// }
// data[fieldKey][v] = value;
//
// } else {
// // Handle regular fields (no colons or single field name)
// // Handle array values (multiple checkboxes/selects)
// if (data[key]) {
// if (!Array.isArray(data[key])) {
// data[key] = [data[key]];
// }
// data[key].push(value);
// } else {
// data[key] = value;
// }
// }
//
// if (post) {
// //Merge back with original
// original[post] = data;
// data = original;
// }
// }
//
// return data;
// }
collectFormData(form) {
const formData = new FormData(form);
let data = {};
const repeaterData = {};
const postData = {};
for (let [key, value] of formData.entries()) {
if (this.ignore.includes(key) || key.endsWith('_temp')) continue;
const processor = this.getFieldProcessor(key);
processor(key, value, data, repeaterData, postData, form);
}
if (!window.isEmptyObject(postData)) {
data = this.mergeRepeaterData(data, repeaterData);
return this.mergePostData(data, postData);
}
return this.mergeRepeaterData(data, repeaterData);
}
getFieldProcessor(key) {
if (key.includes('|')) return this.processTableField;
if (key.includes('::')) return this.processGroupField;
if (key.includes(':')) return this.processRepeaterField;
if (key.includes('[')) return this.processLocationField;
return this.processRegularField;
}
mergeRepeaterData(data, repeaterData) {
Object.keys(repeaterData).forEach(fieldName => {
// Clean up empty rows and convert to array format
const cleanedRows = {};
Object.keys(repeaterData[fieldName]).forEach(index => {
const rowData = repeaterData[fieldName][index];
if (Object.keys(rowData).length > 0) {
cleanedRows[index] = rowData;
}
});
// Convert to sequential array
data[fieldName] = Object.values(cleanedRows);
});
return data;
}
mergePostData(data, postData) {
for (let [postId, postData] in Object.entries(postData)) {
data[postId] = postData;
}
return data;
}
processTableField(key, value, data, repeaterData, postData, form) {
/***
* Table forms are a huge form containing multiple posts and their data
* Field names are prepended with `${postID}|`
* Goal:
* 1) Separate out the post id from the field name
* 2) store the original data in a temporary 'original' variable
* 3) Process the field as normal
* 4) return the original data, as PostID: {$field data}
* Final format:
* {
* id1: {
* field1: "A title",
* field3: 32
* },
* id2: {
* field1: "Another title",
* field2: "122,21,32"
* }
* }
**/
let [post, fieldKey] = key.split('|');
if (!post in postData) {
postData[post] = {};
}
const processor = this.getFieldProcessor(fieldKey);
processor(fieldKey, value, postData, repeaterData, postData, form);
}
processRepeaterField(key, value, data, repeaterData, postData, form) {
let [fieldName, index, subField] = key.split(':');
const isArray = subField.endsWith('[]');
subField = subField.replace('[]', '');
//Ensure this repeater and row is in repeaterData
if (!repeaterData[fieldName]) {
repeaterData[fieldName] = {};
}
if (!repeaterData[fieldName][index]) {
repeaterData[fieldName][index] = {};
}
if (isArray || repeaterData[fieldName][index][subField]) {
// Initialize as array if not already
if (!repeaterData[fieldName][index][subField]) {
repeaterData[fieldName][index][subField] = [];
} else if (!Array.isArray(repeaterData[fieldName][index][subField])) {
repeaterData[fieldName][index][subField] = [repeaterData[fieldName][index][subField]];
}
repeaterData[fieldName][index][subField].push(value);
} else {
// Single value field
repeaterData[fieldName][index][subField] = value;
}
}
processGroupField(key, value, data, repeaterData, postData, form) {
const keys = key.split('::');
const rootGroup = keys[0];
// Initialize root group if it doesn't exist
if (!data[rootGroup]) {
data[rootGroup] = {};
}
// Build nested structure step by step
let current = data[rootGroup];
for (let i = 1; i < keys.length - 1; i++) {
const groupKey = keys[i];
if (!current[groupKey]) {
current[groupKey] = {};
}
current = current[groupKey];
}
// Set the final field value
const fieldKey = keys[keys.length - 1];
// Handle array values (checkboxes, multi-selects)
if (current[fieldKey] !== undefined) {
if (!Array.isArray(current[fieldKey])) {
current[fieldKey] = [current[fieldKey]];
}
current[fieldKey].push(value);
} else {
current[fieldKey] = value;
}
}
processLocationField(key, value, data, repeaterData, postData, form) {
let [fieldKey, v ] = key.split('[');
v = v.replace(']','');
if (!Object.hasOwn(data, fieldKey)) {
data[fieldKey] = {};
if (!Object.hasOwn(data, 'sendAll')) {
data['sendAll'] = [fieldKey];
} else if (!data['sendAll'].includes(fieldKey)) {
data['sendAll'].push(fieldKey);
}
}
data[fieldKey][v] = value;
}
processRegularField(key, value, data, repeaterData, postData, form) {
//handle array values (like checkboxes/selects)
if (data[key]) {
if (!Array.isArray(data[key])) {
data[key] = [data[key]];
}
data[key].push(value);
} else {
data[key] = value;
}
}
/**
* Get differences between old and new data
*/
getDataChanges(newData, oldData, deep = false) {
return window.getDifferences?.map(oldData, newData) || {};
}
async handleSave(changes, formConfig) {
console.log(changes);
if (changes.length === 0 || window.isEmptyObject(changes)) {
return;
}
// Cache the current form state before operation
await this.cacheFormData(formConfig);
if (typeof formConfig.options.onSave === "function") {
formConfig.options.onSave(changes, formConfig);
} else if (formConfig.element.dataset.save) {
let endpoint = formConfig.element.dataset.save;
let title = formConfig.element.dataset.title ?? endpoint;
const operation = {
endpoint: endpoint,
headers: {
'action_nonce': jvbSettings.dash
},
title: `Saving ${title}`,
popup: `Saving ${title}...`,
data: changes,
formId: formConfig.id,
onComplete: (operation) => this.handleOperationComplete(operation),
onUpdate: (operation) => this.handleOperationUpdate(operation)
};
try {
const operationId = await window.jvbQueue.addToQueue(operation);
// Track the operation
this.trackOperation(formConfig.id, operationId, operation);
// Update cached data with operation ID
await this.updateCachedFormOperation(formConfig.id, operationId);
} catch (error) {
this.handleError('Failed to queue form operation:', error);
// Remove from cache if queueing failed
await this.removeCachedForm(formConfig.id);
}
}
}
/**
* Unified save scheduling for both forms and specific fields
* @param {Object} formConfig - Form configuration
* @param {Object} options - Save options
*/
scheduleSave(formConfig, options = {}) {
console.log('Scheduling save...');
const opts = {
type: 'form', // 'form' or 'field'
fieldName: null, // Required for field saves
reason: 'change', // 'change', 'blur', 'add', 'remove', 'reorder'
delay: null, // Uses form saveDelay if null
...options
};
// Generate timeout key
const timeoutKey = opts.type === 'field'
? `${formConfig.id}-${opts.fieldName}-${opts.type}`
: `${formConfig.id}-${opts.type}`;
// Determine delay
const delay = opts.delay ?? this.getSaveDelay(formConfig, opts);
console.log(timeoutKey);
console.log(delay);
this.debouncer.schedule(
timeoutKey,
() => { this.processChanges(formConfig, opts)},
delay
);
}
/**
* Unified change processing for both forms and fields
* @param {Object} formConfig - Form configuration
* @param {Object} options - What to process
*/
processChanges(formConfig, options = {}) {
const opts = {
type: 'form',
fieldName: null,
processSave: true,
... options
};
let changes;
if (opts.type === 'field' && opts.fieldName) {
// Process specific field changes
changes = this.collectFieldChanges(formConfig, opts.fieldName);
} else {
console.log('formConfig: ', formConfig);
// Process entire form changes
const newData = this.collectFormData(formConfig.element);
changes = this.getDataChanges(newData, formConfig.data);
//Check for any fields that are flagged to send all data (mainly location fields)
if (newData.sendAll) {
newData.sendAll.forEach(key => {
changes[key] = newData[key];
});
}
formConfig.data = newData;
}
if (Object.keys(changes).length > 0) {
formConfig.state.isDirty = true;
if (opts.processSave) {
console.log('Handling Save....');
this.handleSave(changes, formConfig);
}
}
}
hasUploadsBlocking(formConfig) {
return formConfig.state.uploadPending || this.hasActiveUploads(formConfig);
}
getSaveDelay(formConfig, options) {
if (options.type === 'field') {
// Use repeater delays for field-level saves
switch (options.reason) {
case 'typing': return this.repeaterDelays.typing;
case 'blur': return this.repeaterDelays.blur;
case 'add': return this.repeaterDelays.add;
case 'remove': return this.repeaterDelays.remove;
case 'reorder': return this.repeaterDelays.reorder;
case 'change':
default: return this.repeaterDelays.change;
}
}
// Use form-level delay
return formConfig.options.saveDelay;
}
/**
* Collect changes for a specific field (used for repeaters)
*/
collectFieldChanges(formConfig, fieldName) {
const currentData = this.collectFormData(formConfig.element);
// Return only the specified field data
const fieldChanges = {
[fieldName]: currentData[fieldName] || []
};
// Update stored data for this field
if (!formConfig.data) {
formConfig.data = {};
}
formConfig.data[fieldName] = currentData[fieldName];
return fieldChanges;
}
/************************************************************
*
* CONDITIONAL LOGIC
*
************************************************************/
initConditionalFields(formConfig) {
const form = formConfig.element;
form.querySelectorAll('[data-depends-on]').forEach(field => {
const dependsOn = field.dataset.dependsOn;
if (!formConfig.dependencies.has(dependsOn)) {
formConfig.dependencies.set(dependsOn, []);
}
formConfig.dependencies.get(dependsOn).push(field);
const triggerValue = this.getFieldValue(form, dependsOn);
const requiredValue = field.dataset.dependsValue;
const operator = field.dataset.dependsOperator || '==';
const shouldShow = this.evaluateCondition(triggerValue, requiredValue, operator);
this.toggleFieldVisibility(field, shouldShow);
});
}
handleSpecialFields(event, formConfig) {
// Handle repeater field changes
if (event.target.closest('.repeater-row')) {
this.updateRepeaterOrder(event.target.closest('.repeater'));
}
}
checkConditionals(changed, formConfig) {
const dependencies = formConfig.dependencies.get(changed);
if (!dependencies) return;
this.updateConditionalFields(changed, dependencies, formConfig);
}
updateConditionalFields(changed, dependencies, formConfig) {
const triggerValue = this.getFieldValue(formConfig.element, changed);
dependencies.forEach(field => {
this.checkDependencies(field, triggerValue);
});
}
checkDependencies(field, value) {
const requiredValue = field.dataset.dependsValue;
const operator = field.dataset.dependsOperator || '==';
const shouldShow = this.evaluateCondition(value, requiredValue, operator);
this.toggleFieldVisibility(field, shouldShow);
}
evaluateCondition(fieldValue, requiredValue, operator) {
// Handle null/undefined values
if (fieldValue === null || fieldValue === undefined) {
fieldValue = '';
}
if (requiredValue === null || requiredValue === undefined) {
requiredValue = '';
}
// Convert both to strings for consistent comparison (since form values are always strings)
const fieldStr = String(fieldValue);
const requiredStr = String(requiredValue);
switch (operator) {
case '==':
return fieldStr == requiredStr; // Loose equality
case '!=':
return fieldStr != requiredStr; // Loose inequality
case '===':
return fieldStr === requiredStr; // Strict equality (if needed)
case '!==':
return fieldStr !== requiredStr; // Strict inequality (if needed)
case '>':
return Number(fieldValue) > Number(requiredValue);
case '>=':
return Number(fieldValue) >= Number(requiredValue);
case '<':
return Number(fieldValue) < Number(requiredValue);
case '<=':
return Number(fieldValue) <= Number(requiredValue);
case 'contains':
return fieldStr.toLowerCase().includes(requiredStr.toLowerCase());
case 'not_contains':
return !fieldStr.toLowerCase().includes(requiredStr.toLowerCase());
case 'starts_with':
return fieldStr.toLowerCase().startsWith(requiredStr.toLowerCase());
case 'ends_with':
return fieldStr.toLowerCase().endsWith(requiredStr.toLowerCase());
case 'empty':
return fieldStr.trim() === '';
case 'not_empty':
return fieldStr.trim() !== '';
case 'in':
// For array/comma-separated values: requiredValue = "option1,option2,option3"
const options = requiredStr.split(',').map(opt => opt.trim());
return options.includes(fieldStr);
case 'not_in':
const notOptions = requiredStr.split(',').map(opt => opt.trim());
return !notOptions.includes(fieldStr);
default:
return false;
}
}
getFieldValue(form, fieldName) {
const field = form.querySelector(`[name="${fieldName}"]`);
if (!field) return null;
if (field.type === 'radio') {
const checked = form.querySelector(`[name="${fieldName}"]:checked`);
return checked ? checked.value : null;
} else if (field.type === 'checkbox') {
return field.checked ? (field.value || '1') : '';
}
return field.value;
}
toggleFieldVisibility(field, show) {
const wrapper = field.closest('.field, fieldset');
if (!wrapper) return;
wrapper.hidden = !show;
wrapper.querySelectorAll('input, select, textarea').forEach(control => {
control.hidden = !show;
control.disabled = !show;
});
}
/**************************************************
*
* REPEATER FUNCTIONALITY
*
**************************************************/
initRepeaterFields(form) {
form.querySelectorAll('.repeater').forEach(repeater => {
this.hasRepeaters = true;
const addButton = repeater.querySelector('.add-repeater-row');
let temp = repeater.querySelector('template').className;
let template = window.getTemplate(temp);
const container = repeater.querySelector('.repeater-items');
if (!addButton || !template || !container) {
console.warn('Missing required repeater elements:',
{addButton, template, container});
return;
}
// Initialize Sortable
new Sortable(container, {
handle: '.repeater-row-header',
animation: 150,
onEnd: () => {
this.updateRepeaterOrder(repeater);
}
});
});
}
addRepeaterRow(repeater, formConfig) {
let rows = repeater.querySelector('.repeater-items');
let index = rows.children.length;
let row = window.getTemplate(repeater.querySelector('template').className);
// Set a proper ID for the row
row.id = `${formConfig.id}-${repeater.dataset.field}-row-${index}`;
let base = repeater.dataset.field;
row.querySelectorAll('input, select, textarea').forEach((field) => {
let label = field.nextElementSibling;
if (!label || label.tagName !== 'LABEL') {
label = field.closest('.field')?.querySelector(`label`);
}
let id = `${base}:${index}:${field.name}-${field.value}`;
let name = `${base}:${index}:${field.name}`;
[field.name, field.id, label.htmlFor] = [name, id, id];
});
[row.dataset.index, row.querySelector('.row-number').textContent] = [index, `#${index + 1}`];
rows.append(row);
// Focus the first input in the new row
const firstInput = row.querySelector('input, select, textarea');
if (firstInput) {
firstInput.focus();
}
}
removeRepeaterRow(removeButton, formConfig) {
let repeater = removeButton.closest('.repeater');
removeButton.closest('.repeater-row').remove();
this.updateRepeaterOrder(repeater);
}
updateRepeaterOrder(repeater) {
requestAnimationFrame(() => {
let items = repeater.querySelector('.repeater-items');
let updates = [];
let base = repeater.dataset.field;
const form = repeater.closest('form');
const formConfig = this.forms.get(form.dataset.formId);
// Update field names and row numbers
items.querySelectorAll('.repeater-row').forEach((row, index) => {
updates.push(() => {
[
row.dataset.index,
row.querySelector('.row-number').textContent
] = [
index,
`#${index + 1}`
];
row.querySelectorAll('[name]').forEach(field => {
let name = field.name.split(':').pop();
let newName = `${base}:${index}:${name}`;
let newId = `${base}-${index}-${name}-${field.value}`;
[
field.name,
field.id
] = [
newName,
newId
];
let label = field.closest('.field').querySelector('label');
if (label) {
label.htmlFor = newId;
}
});
});
});
updates.forEach(update => update());
// Schedule save after reorder
this.scheduleSave(formConfig, {
type: 'field',
fieldName: base,
reason: 'reorder',
delay: this.repeaterDelays.reorder
});
});
}
getRepeaterChangeDelay(field, eventType) {
// Text inputs get longer delays to allow for typing
if ((field.type === 'text' || field.type === 'textarea') && eventType === 'input') {
return this.repeaterDelays.typing;
}
// Other input types get standard change delay
if (eventType === 'change') {
return this.repeaterDelays.change;
}
// Blur events get shorter delay
if (eventType === 'blur') {
return this.repeaterDelays.blur;
}
return this.repeaterDelays.change;
}
generateRowId(repeaterRow) {
if (repeaterRow.id) return repeaterRow.id;
// Generate ID based on form and row position
const form = repeaterRow.closest('form');
const repeater = repeaterRow.closest('.repeater');
const index = Array.from(repeater.querySelectorAll('.repeater-row')).indexOf(repeaterRow);
const repeaterId = repeater.dataset.field || 'repeater';
const generatedId = `${form.dataset.formId}-${repeaterId}-row-${index}`;
repeaterRow.id = generatedId;
return generatedId;
}
/*****************************************************
*
* FIELD POPULATION (used by CRUD edit/bulk-edit modals and table view)
*
*****************************************************/
/**
* Populate form fields with data values
* @param {HTMLFormElement} form - The form element
* @param {Object} fieldsData - Field values from API
* @param {Object} imagesData - Image metadata (optional)
* @param {Object} options - Additional options
*/
populateFormFields(form, fieldsData, imagesData = {}, options = {}) {
const formConfig = this.forms.get(form.dataset.formId);
// Build field type cache once
if (!formConfig.fieldTypeCache) {
formConfig.fieldTypeCache = new Map();
form.querySelectorAll('.field').forEach(field => {
const fieldName = field.dataset.field;
if (fieldName) {
formConfig.fieldTypeCache.set(fieldName, this.getFieldType(field));
}
});
}
// Use cached types
for (let [fieldName, fieldValue] of Object.entries(fieldsData)) {
let fieldWrapper = form.querySelector(`[data-field=${fieldName}]`);
if (fieldWrapper) {
this.populateFieldValue(fieldWrapper, fieldName, fieldValue, imagesData, options);
}
}
}
/**
* Populate a single field with its value
* @param {HTMLElement} fieldWrapper - The field wrapper element
* @param {string} fieldName - Field name
* @param {*} fieldValue - Field value
* @param {Object} imagesData - Image metadata
* @param {Object} options - Additional options
*/
populateFieldValue(fieldWrapper, fieldName, fieldValue, imagesData = {}, options = {}) {
if (!fieldWrapper || fieldValue === undefined || fieldValue === null) {
return;
}
// Determine field type from classes or data attributes
const fieldType = this.getFieldType(fieldWrapper);
switch (fieldType) {
case 'image':
this.populateImageField(fieldWrapper, fieldName, fieldValue, imagesData);
break;
case 'gallery':
this.populateGalleryField(fieldWrapper, fieldName, fieldValue, imagesData);
break;
case 'repeater':
this.populateRepeaterField(fieldWrapper, fieldName, fieldValue, options);
break;
case 'taxonomy':
this.populateTaxonomyField(fieldWrapper, fieldName, fieldValue);
break;
case 'user':
this.populateUserField(fieldWrapper, fieldName, fieldValue);
break;
case 'location':
this.populateLocationField(fieldWrapper, fieldName, fieldValue);
break;
case 'set':
case 'checkbox':
this.populateSetField(fieldWrapper, fieldName, fieldValue);
break;
case 'select':
case 'radio':
this.populateSelectField(fieldWrapper, fieldName, fieldValue);
break;
case 'true_false':
this.populateBooleanField(fieldWrapper, fieldName, fieldValue);
break;
case 'date':
case 'time':
case 'datetime':
this.populateDateField(fieldWrapper, fieldName, fieldValue);
break;
case 'number':
this.populateNumberField(fieldWrapper, fieldName, fieldValue);
break;
case 'textarea':
if (fieldWrapper.querySelector('.editor-container')) {
this.populateEditorField(fieldWrapper, fieldName, fieldValue);
} else {
this.populateTextareaField(fieldWrapper, fieldName, fieldValue);
}
break;
case 'text':
case 'email':
case 'url':
case 'tel':
case 'phone':
default:
this.populateTextField(fieldWrapper, fieldName, fieldValue);
break;
}
}
/**
* Determine field type from wrapper element
* @param {HTMLElement} fieldWrapper - Field wrapper element
* @returns {string} Field type
*/
getFieldType(fieldWrapper) {
// Check for specific field classes
const typeClasses = [
'image', 'gallery', 'repeater', 'taxonomy', 'user', 'location',
'set', 'checkbox', 'select', 'radio', 'true_false', 'date',
'time', 'datetime', 'editor', 'number', 'text', 'textarea',
'email', 'url', 'tel', 'phone'
];
for (const type of typeClasses) {
if (fieldWrapper.classList.contains(type)) {
return type;
}
}
// Check for data attribute
if (fieldWrapper.dataset.type) {
return fieldWrapper.dataset.type;
}
// Check input type
const input = fieldWrapper.querySelector('input, select, textarea');
if (input) {
if (input.tagName === 'TEXTAREA') {
return input.dataset.editor === 'true' ? 'editor' : 'textarea';
}
if (input.type) {
return input.type === 'checkbox' && !fieldWrapper.classList.contains('true_false') ? 'set' : input.type;
}
}
return 'text';
}
/**
* Populate text-based fields
*/
populateTextField(fieldWrapper, fieldName, fieldValue) {
const input = fieldWrapper.querySelector(`[name="${fieldName}"], input, textarea`);
if (input) {
input.value = String(fieldValue || '');
// Update character counter if present
if (input.dataset.limit) {
const counter = fieldWrapper.querySelector('.char-count .current');
if (counter) {
counter.textContent = input.value.length;
}
}
}
}
/**
* Populate textarea fields
*/
populateTextareaField(fieldWrapper, fieldName, fieldValue) {
const textarea = fieldWrapper.querySelector(`textarea[name="${fieldName}"]`) ||
fieldWrapper.querySelector('textarea:not([data-editor="true"])');
if (textarea) {
const oldValue = textarea.value;
textarea.value = String(fieldValue || '');
// Trigger change event to update any dependencies
textarea.dispatchEvent(new Event('change', { bubbles: true }));
// Update character counter if present
if (textarea.dataset.limit) {
const counter = fieldWrapper.querySelector('.char-count .current');
if (counter) {
counter.textContent = textarea.value.length;
// Check if limit is reached
const limit = parseInt(textarea.dataset.limit, 10);
if (textarea.value.length >= limit) {
fieldWrapper.classList.add('reached');
} else {
fieldWrapper.classList.remove('reached');
}
}
}
} else {
console.warn(`No textarea found for field ${fieldName} in wrapper:`, fieldWrapper);
}
}
/**
* Populate number fields
*/
populateNumberField(fieldWrapper, fieldName, fieldValue) {
const input = fieldWrapper.querySelector(`[name="${fieldName}"], input[type="number"]`);
if (input) {
input.value = Number(fieldValue) || 0;
}
}
/**
* Populate boolean/true_false fields
*/
populateBooleanField(fieldWrapper, fieldName, fieldValue) {
const input = fieldWrapper.querySelector(`[name="${fieldName}"], input[type="checkbox"]`);
if (input) {
input.checked = Boolean(fieldValue);
}
}
/**
* Populate select/radio fields
*/
populateSelectField(fieldWrapper, fieldName, fieldValue) {
const value = String(fieldValue || '');
// Try select first
const select = fieldWrapper.querySelector(`select[name="${fieldName}"]`);
if (select) {
select.value = value;
return;
}
// Try radio buttons
const radio = fieldWrapper.querySelector(`input[type="radio"][name="${fieldName}"][value="${value}"]`);
if (radio) {
radio.checked = true;
}
}
/**
* Populate set/checkbox fields (multiple selections)
*/
populateSetField(fieldWrapper, fieldName, fieldValue) {
// Parse value if it's a string
let values = fieldValue;
if (typeof fieldValue === 'string') {
try {
values = JSON.parse(fieldValue);
} catch (e) {
values = fieldValue.split(',').map(v => v.trim());
}
}
if (!Array.isArray(values)) {
values = [String(values)];
}
// Update checkboxes
fieldWrapper.querySelectorAll(`input[type="checkbox"][name*="${fieldName}"]`).forEach(checkbox => {
checkbox.checked = values.includes(checkbox.value);
});
}
/**
* Populate date/time fields
*/
populateDateField(fieldWrapper, fieldName, fieldValue) {
const input = fieldWrapper.querySelector(`[name="${fieldName}"], input`);
if (input && fieldValue) {
// Handle different date formats
let dateValue = fieldValue;
if (typeof fieldValue === 'object' && fieldValue.date) {
dateValue = fieldValue.date;
}
// Convert to appropriate format for input type
try {
const date = new Date(dateValue);
if (!isNaN(date.getTime())) {
switch (input.type) {
case 'date':
input.value = date.toISOString().split('T')[0];
break;
case 'time':
input.value = date.toTimeString().slice(0, 5);
break;
case 'datetime-local':
input.value = date.toISOString().slice(0, 16);
break;
default:
input.value = dateValue;
}
}
} catch (e) {
input.value = dateValue;
}
}
}
/**
* Populate editor fields (Quill)
*/
populateEditorField(fieldWrapper, fieldName, fieldValue) {
const textarea = fieldWrapper.querySelector(`textarea[name="${fieldName}"]`) ||
fieldWrapper.querySelector('textarea[data-editor="true"]') ||
fieldWrapper.querySelector('textarea');
if (!textarea) {
console.warn(`Editor field ${fieldName}: textarea not found`);
return;
}
const content = String(fieldValue || '');
// Update the textarea value
textarea.value = content;
// Try to find and update Quill editor
const editorContainer = fieldWrapper.querySelector('.editor');
if (editorContainer) {
// Try different ways to access the Quill instance
let quillInstance = null;
// Method 1: Check if Quill is stored on the editor element
if (editorContainer.__quill) {
quillInstance = editorContainer.__quill;
}
// Method 2: Check if Quill is stored as quill property
else if (editorContainer.quill) {
quillInstance = editorContainer.quill;
}
// Method 3: Try to find Quill in the global registry (if you have one)
else if (window.Quill && window.Quill.find) {
quillInstance = window.Quill.find(editorContainer);
}
// Method 4: Check all Quill instances if available
else if (window.Quill && window.Quill.instances) {
// Some setups store instances in a registry
for (let instance of window.Quill.instances) {
if (instance.container === editorContainer) {
quillInstance = instance;
break;
}
}
}
if (quillInstance) {
// Set the content in Quill
quillInstance.root.innerHTML = content;
// Store the instance reference for future use
editorContainer.__quill = quillInstance;
} else {
console.warn(`Quill instance not found for ${fieldName}, setting HTML directly`);
// Fallback: set HTML directly
editorContainer.innerHTML = content;
}
} else {
console.warn(`Editor container not found for ${fieldName}`);
}
// Trigger change event on textarea
textarea.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* Populate location fields
*/
populateLocationField(fieldWrapper, fieldName, fieldValue) {
if (!fieldValue || typeof fieldValue !== 'object') {
return;
}
// Location fields typically have sub-fields
const subFields = ['address', 'lat', 'lng', 'street', 'city', 'province', 'postal_code', 'country'];
subFields.forEach(subField => {
if (fieldValue[subField] !== undefined) {
const input = fieldWrapper.querySelector(`[name="${fieldName}_${subField}"], [name="${subField}"]`);
if (input) {
input.value = String(fieldValue[subField] || '');
}
}
});
}
/**
* Populate taxonomy fields
*/
populateTaxonomyField(fieldWrapper, fieldName, fieldValue) {
// Handle different value formats
let termIds = [];
if (Array.isArray(fieldValue)) {
termIds = fieldValue.map(v => String(v));
} else if (typeof fieldValue === 'string') {
try {
const parsed = JSON.parse(fieldValue);
termIds = Array.isArray(parsed) ? parsed.map(v => String(v)) : [String(parsed)];
} catch (e) {
termIds = fieldValue.split(',').map(v => v.trim());
}
} else if (fieldValue) {
termIds = [String(fieldValue)];
}
if (termIds.length === 0) {
return;
}
// Update hidden input
const hiddenInput = fieldWrapper.querySelector(`input[type="hidden"][name="${fieldName}"]`);
if (hiddenInput) {
hiddenInput.value = termIds.join(',');
}
// Update taxonomy selector if present
if (fieldWrapper.dataset.fieldId && window.jvbSelector) {
window.jvbSelector.updateFieldFromInput(fieldWrapper.dataset.fieldId);
}
}
/**
* Populate user fields (similar to taxonomy)
*/
populateUserField(fieldWrapper, fieldName, fieldValue) {
// Similar logic to taxonomy fields
this.populateTaxonomyField(fieldWrapper, fieldName, fieldValue);
}
/**
* Populate image fields
*/
populateImageField(fieldWrapper, fieldName, fieldValue, imagesData = {}) {
if (!fieldValue) {
return;
}
// Handle comma-separated IDs or single ID
const imageIds = String(fieldValue).split(',').filter(id => parseInt(id.trim()));
if (imageIds.length === 0) {
return;
}
// Update hidden input
const hiddenInput = fieldWrapper.querySelector(`input[type="hidden"][name="${fieldName}"]`);
if (hiddenInput) {
hiddenInput.value = imageIds.join(',');
}
// Update image display
const grid = fieldWrapper.querySelector('.item-grid');
const uploadContainer = fieldWrapper.querySelector('.file-upload-container');
if (grid) {
imageIds.forEach(imageId => {
let image = window.getTemplate('uploadItem');
let img = image.querySelector('img');
let details = image.querySelector('details');
let meta = window.getTemplate('uploadMeta')
details.append(meta);
[
img.src,
img.alt,
image.querySelector('[name="image-title"]').value,
image.querySelector('[name="image-alt-text"]').value,
image.querySelector('[name="image-caption"]').value,
] = [
imagesData[imageId].medium,
imagesData[imageId].alt,
imagesData[imageId].title,
imagesData[imageId].alt,
imagesData[imageId].caption,
];
details.querySelector('.upload-meta > .hint')?.remove();
grid.append(image);
});
if (imageIds.length > 0) {
if (uploadContainer) {
uploadContainer.hidden = true;
}
}
}
}
/**
* Populate gallery fields
*/
populateGalleryField(fieldWrapper, fieldName, fieldValue, imagesData = {}) {
this.populateImageField(fieldWrapper, fieldName, fieldValue, imagesData);
}
/**
* Populate repeater fields
*/
populateRepeaterField(fieldWrapper, fieldName, fieldValue, options = {}) {
if (!fieldValue || !Array.isArray(fieldValue)) {
return;
}
const container = fieldWrapper.querySelector('.repeater-items');
const template = fieldWrapper.querySelector('template');
if (!container || !template) {
console.warn(`Repeater field ${fieldName}: missing container or template`);
return;
}
// Clear existing rows
window.removeChildren(container);
// Create rows for each data item
fieldValue.forEach((rowData, index) => {
if (!rowData || typeof rowData !== 'object') {
return;
}
const row = window.getTemplate(template.className);
if (!row) {
console.warn(`Repeater field ${fieldName}: template not found`);
return;
}
// Set row ID and update row number
row.id = `${fieldWrapper.closest('form').id}-${fieldName}-row-${index}`;
row.dataset.index = index;
const rowNumber = row.querySelector('.row-number');
if (rowNumber) {
rowNumber.textContent = `#${index + 1}`;
}
// Update field names and populate values
row.querySelectorAll('input, select, textarea').forEach(field => {
const originalName = field.name;
const newName = `${fieldName}:${index}:${originalName}`;
const newId = `${fieldName}-${index}-${originalName}-${field.value}`;
// Update field identifiers
field.name = newName;
field.id = newId;
// Update label
const label = field.nextElementSibling;
if (label && label.tagName === 'LABEL') {
label.htmlFor = newId;
}
// Populate field value
if (rowData[originalName] !== undefined) {
this.populateRepeaterFieldValue(field, originalName, rowData[originalName]);
}
});
container.appendChild(row);
});
}
/**
* Populate individual repeater field value
*/
populateRepeaterFieldValue(field, fieldName, fieldValue) {
switch (field.type) {
case 'checkbox':
field.checked = Boolean(fieldValue);
break;
case 'radio':
field.checked = field.value === String(fieldValue);
break;
case 'select-one':
case 'select-multiple':
field.value = String(fieldValue || '');
break;
default:
field.value = String(fieldValue || '');
}
}
/*****************************************************
*
* QUILL
*
*****************************************************/
initQuillEditor(formConfig) {
let form = formConfig.element;
const textareas = form.querySelectorAll('textarea[data-editor=true]');
textareas.forEach(textarea => {
let container, editor, toolbar;
//create it if it doesn't exist
if(!textarea.parentNode.querySelector('.editor-container')){
container = document.createElement('div');
container.className = 'editor-container';
editor = document.createElement('div');
editor.className = 'editor';
toolbar = document.createElement('div');
toolbar.className = 'toolbar';
const image = textarea.dataset.allowimage === true ? `` : '';
toolbar.id = `toolbar-${textarea.id}`;
toolbar.innerHTML = `
${image}
`;
container.appendChild(toolbar);
container.appendChild(editor);
textarea.parentNode.insertBefore(container, textarea);
textarea.style.display = 'none';
editor.innerHTML = textarea.value;
}else{
container = textarea.parentNode.querySelector('.editor-container');
editor = container.querySelector('.editor');
toolbar = container.querySelector('.toolbar');
}
const quill = new Quill(editor, {
theme: 'snow',
modules: {
toolbar: {
container: toolbar,
handlers: {
p: function() { this.quill.format('header', false); },
h1: function() { this.quill.format('header', 1); },
h2: function() { this.quill.format('header', 2); },
h3: function() { this.quill.format('header', 3); },
jvb_bold: function() {this.quill.format('bold', true)},
jvb_italic: function() {this.quill.format('italic', true)},
jvb_strike: function() {this.quill.format('strike', true)},
jvb_underline: function() {this.quill.format('underline', true)},
'jvb_align': function(value) {
this.quill.format('align', value === this.quill.getFormat().list ? false : value);
},
'jvb_list': function(value) {
this.quill.format('list', value === this.quill.getFormat().list ? false : value);
},
'jvb_link': function(value) {
if (value) {
const range = this.quill.getSelection();
if (range == null || range.length === 0) return;
// Get the existing link if any
const preview = this.quill.getText(range.index, range.length);
const existingLink = this.quill.getFormat(range).link;
// Create modal for link input
const modal = document.createElement('dialog');
modal.className = 'quill-link-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.showModal();
const input = modal.querySelector('input');
input.focus();
// Handle save
modal.querySelector('.save').addEventListener('click', () => {
const url = input.value;
if (url) {
this.quill.format('link', url);
}
modal.remove();
});
// Handle remove if link exists
const removeBtn = modal.querySelector('.remove');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
this.quill.format('link', false);
modal.remove();
});
}
// Handle cancel
modal.querySelector('.cancel').addEventListener('click', () => {
modal.remove();
});
// Handle Enter key
input.addEventListener('keyup', (e) => {
if (e.key === 'Enter') {
const url = input.value;
if (url) {
this.quill.format('link', url);
}
modal.remove();
}
});
}
},
'jvb_image': function() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/jpeg,image/png,image/gif,image/webp');
input.style.display = 'none';
document.body.appendChild(input);
input.onchange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file
const maxSize = 5242880; // 5MB
if (file.size > maxSize) {
this.quill.insertText(range.index, 'File too large. Maximum size is 5MB', {
'color': '#f00',
'italic': true
}, true);
input.remove();
return;
}
const range = this.quill.getSelection(true);
const formData = new FormData();
formData.append('image', file);
if (objectID) {
formData.append('post_id', objectID);
}
// Show loading state
if (window.jvbLoading) {
window.jvbLoading.showLoading('Uploading image...', 'Processing Upload');
}
try {
const response = await fetch(
`${jvbSettings.api}uploads/`,
{
method: 'POST',
headers: {
'X-WP-Nonce': jvbSettings.nonce
},
body: formData
}
);
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
// Insert the image at cursor position
this.quill.insertEmbed(range.index, 'image', result.url);
} catch (error) {
this.handleError('Upload error:', error);
this.quill.insertText(range.index, 'Failed to upload image. Please try again.', {
'color': '#f00',
'italic': true
}, true);
} finally {
if (window.jvbLoading) {
window.jvbLoading.hide();
}
input.remove();
}
};
input.click();
}
}
},
history: {
delay: 2000,
maxStack: 500
},
clipboard: {
matchVisual: false
}
}
});
quill.on('selection-change', function(range) {
const alignmentTools = toolbar.querySelector('.ql-align');
if (alignmentTools) {
if (range && range.length === 0) {
// Get the focused element
const [leaf] = this.quill.getLeaf(range.index);
if (leaf && leaf.domNode && leaf.domNode.tagName === 'IMG') {
alignmentTools.style.display = 'inline-block';
return;
}
}
alignmentTools.style.display = 'none';
}
});
// Update hidden textarea and trigger form change
quill.on('text-change', () => {
textarea.value = quill.root.innerHTML;
textarea.dispatchEvent(new Event('change', { bubbles: true }));
});
});
}
/*****************************************************
*
* CHARACTER LIMITS
*
*****************************************************/
initCharacterLimits(formConfig) {
formConfig.element.querySelectorAll('input[data-limit], textarea[data-limit]').forEach(input => {
const counter = input.closest('.field').querySelector('.char-count .current');
if (counter) {
const updateCount = () => {
const limit = parseInt(input.dataset.limit, 10);
// Update the counter
counter.textContent = input.value.length;
// If length exceeds limit, truncate the input value
if (input.value.length > limit) {
input.closest('.field').classList.add('reached');
input.value = input.value.substring(0, limit);
counter.textContent = limit; // Update counter after truncation
}else {
input.closest('.field').classList.remove('reached');
}
};
input.addEventListener('input', updateCount);
updateCount(); // Initial count
}
})
}
/*****************************************************
*
* NUMBER FIELDS
*
*****************************************************/
handleNumberClick(e, container) {
let change = 0;
if (e.target.closest('.increase')) {
change += 1;
} else if (e.target.closest('.decrease')) {
change -=1;
}
if (change !== 0) {
let [
step,
input
] = [
parseInt(container.dataset.step),
container.querySelector('input'),
];
let value = (input.value === '') ? 0 : parseInt(input.value);
input.value = (value + (step * change * this.stepMultiplier));
this.handleNumberLimits(container);
}
}
handleNumberLimits(container) {
let [
min,
max,
input,
increase,
decrease
] = [
container.dataset.min,
container.dataset.max,
container.querySelector('input'),
container.querySelector('.increase'),
container.querySelector('.decrease')
];
let value = parseInt(input.value);
if (value < min) {
input.value = min;
decrease.disabled = true;
} else if (value > max) {
input.value = max;
increase.disabled = false;
} else if (increase.disabled) {
increase.disabled = false;
} else if (decrease.disabled) {
decrease.disabled = false;
}
}
/*****************************************************
*
* GALLERY
* TODO: Does the uploader handle this on its own now?
*
*****************************************************/
initImageFields(formConfig) {
formConfig.element.querySelectorAll('.image').forEach(field => {
const fieldName = field.querySelector('input[type="hidden"]').name;
const fileInput = field.querySelector('input[type="file"]');
if (!fileInput) return;
// Register with centralized upload manager
const fieldId = window.jvbUploadManager.registerUploader(field, {
content: formConfig.options.content,
postId: formConfig.options.itemID,
mode: 'direct',
uploadType: 'image_upload',
fieldName: fieldName,
maxFiles: 1,
autoSubmit: true,
// Custom callbacks for this field type
onUploadStart: () => {
formConfig.state.uploadPending = true;
},
onUploadComplete: (result) => {
formConfig.state.uploadPending = false;
this.handleImageUploadSuccess(result, field);
},
onUploadError: (error) => {
formConfig.state.uploadPending = false;
this.handleImageUploadError(error, field);
}
});
// Store field reference for cleanup
if (!formConfig.uploadFields) {
formConfig.uploadFields = new Set();
}
formConfig.uploadFields.add(fieldId);
});
}
handleImageRemove(field) {
const imageDisplay = field.querySelector('.image-display');
const img = imageDisplay.querySelector('img');
const hiddenInput = field.querySelector('input[type="hidden"]');
const uploadContainer = field.querySelector('.file-upload-container');
// Clear the hidden input
hiddenInput.value = '';
// Reset UI
img.src = '';
imageDisplay.classList.remove('has-image');
uploadContainer.hidden = false;
// Show notification
this.showNotification('Image removed');
}
handleImageUploadSuccess(result, field) {
if (!result.data || !result.data.length) return;
const imageDisplay = field.querySelector('.image-display');
removeChildren(imageDisplay);
imageDisplay.classList.add('has-image');
let ids = [];
result.data.forEach(file => {
let img = new Image();
img.src = file.url;
ids.push(file.attachment_id);
imageDisplay.appendChild(img);
});
const hiddenInput = field.querySelector('input[type="hidden"]');
hiddenInput.value = ids.join(',');
const uploadContainer = field.querySelector('.file-upload-container');
uploadContainer.hidden = true;
// Trigger form change event
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
this.showNotification('Image updated successfully');
}
handleImageUploadError(error, field) {
this.handleError('Upload error:', error);
// Clear upload pending state
const form = field.closest('form');
if (form && form.dataset.formId) {
const formConfig = this.forms.get(form.dataset.formId);
if (formConfig) {
formConfig.state.uploadPending = false;
}
}
this.showNotification('Failed to upload image', 'error');
// Reset field if needed
const uploadContainer = field.querySelector('.file-upload-container');
uploadContainer.hidden = false;
// Clear any error states
const errorElement = field.querySelector('.file-error');
if (errorElement) {
errorElement.textContent = '';
}
}
initGalleryFields(formConfig) {
formConfig.element.querySelectorAll('.gallery').forEach(field => {
const fieldName = field.querySelector('input[type="hidden"]').name;
const previewGrid = field.querySelector('.gallery-preview');
// Pre-populate existing images
if (field.dataset.images) {
const urls = field.dataset.images.split(',');
urls.forEach(url => {
this.addToGalleryPreview(url, previewGrid);
});
}
// Register with centralized upload manager
const fieldId = window.jvbUploadManager.registerUploader(field, {
content: formConfig.options.content,
postId: formConfig.options.itemID,
mode: 'gallery',
uploadType: 'image_upload',
fieldName: fieldName,
maxFiles: 20,
allowMultiple: true,
groupable: true,
onUploadComplete: (result) => {
this.handleGalleryUploadSuccess(result, field);
},
});
// Store field reference
if (!formConfig.uploadFields) {
formConfig.uploadFields = new Set();
}
formConfig.uploadFields.add(fieldId);
});
}
handleGalleryUploadSuccess(result, field) {
if (!result.data || !result.data.length) return;
const hiddenInput = field.querySelector('input[type="hidden"]');
const previewGrid = field.querySelector('.gallery-preview');
const currentIds = hiddenInput.value ? hiddenInput.value.split(',') : [];
result.data.forEach(file => {
currentIds.push(file.attachment_id);
this.addToGalleryPreview(file.url, previewGrid);
});
hiddenInput.value = currentIds.join(',');
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
this.showNotification(`Added ${result.data.length} image(s) to gallery`);
}
addToGalleryPreview(url, grid) {
let preview = window.getTemplate('galleryPreview');
let img = preview.querySelector('img');
img.src = url;
grid.appendChild(preview);
return preview;
}
/*****************************************************
*
* UPLOAD INTEGRATION
* TODO: This may not be necessary. I believe the uploads handles any form changes in the backend
*
*****************************************************/
hasActiveUploads(formConfig) {
if (!formConfig.uploadFields || !window.jvbUploadManager) {
return false;
}
// Check each upload field for active uploads
for (const fieldId of formConfig.uploadFields) {
const status = window.jvbUploadManager.getFieldStatus(fieldId);
if (status && (status.uploading > 0 || status.ready > 0)) {
return true;
}
}
return false;
}
/***************************************************
*
*
*
***************************************************/
showNotification(msg, type){
window.jvbNotifications.showToast(msg, type);
}
getForm(formId) {
return this.forms.get(formId);
}
processForm(formId) {
const formConfig = this.forms.get(formId);
if (formConfig) {
this.processChanges(formConfig);
}
}
isFormReadyToSave(formId) {
const formConfig = this.forms.get(formId);
if (!formConfig) return true;
return !formConfig.state.uploadPending && !this.hasActiveUploads(formConfig);
}
getFormUploadProgress(formId) {
const formConfig = this.forms.get(formId);
if (!formConfig || !formConfig.uploadFields) return null;
let totalUploads = 0;
let completedUploads = 0;
let failedUploads = 0;
formConfig.uploadFields.forEach(fieldId => {
const status = window.jvbUploadManager.getFieldStatus(fieldId);
if (status) {
totalUploads += status.uploadCount;
completedUploads += status.completed;
failedUploads += status.failed;
}
});
return {
total: totalUploads,
completed: completedUploads,
failed: failedUploads,
pending: totalUploads - completedUploads - failedUploads,
progress: totalUploads > 0 ? (completedUploads / totalUploads) * 100 : 0
};
}
// Remove form from management
removeForm(formId) {
const formConfig = this.forms.get(formId);
if (!formConfig) return;
// Clean up regular timeouts
if (formConfig.state.saveTimeout) {
clearTimeout(formConfig.state.saveTimeout);
}
// Clean up upload fields
if (formConfig.uploadFields && window.jvbUploadManager) {
formConfig.uploadFields.forEach(fieldId => {
console.log(`Cleaning up upload field: ${fieldId}`);
});
}
// Clean up cached data
this.removeCachedForm(formId);
// Clean up active operations
if (formConfig.state.operationId) {
this.activeOperations.delete(formConfig.state.operationId);
}
this.forms.delete(formId);
}
cleanup() {
// Clean up debouncer
if (this.debouncer) {
this.debouncer.cleanup();
}
// Clear all maps
this.activeRepeaters.clear();
this.activeOperations.clear();
this.pendingForms.clear();
// Clean up all forms
for (let [formId, formConfig] of this.forms) {
this.removeForm(formId);
}
// Remove event listeners
if (this.initialized) {
document.removeEventListener('submit', this.submitHandler);
document.removeEventListener('change', this.changeHandler);
document.removeEventListener('click', this.clickHandler);
document.removeEventListener('keydown', this.keyHandler);
document.removeEventListener('focusin', this.focusHandler);
document.removeEventListener('focusout', this.blurHandler);
this.initialized = false;
}
}
handleError(error, context) {
// In production, send to error tracking service
if (window.jvbError) {
window.jvbError.log(error, context);
}
// In development only
if (jvbSettings.debug) {
console.error(context, error);
}
}
}
// Initialize singleton with auto-scanning
document.addEventListener('DOMContentLoaded', () => {
if (!window.jvbForm) {
window.jvbForm = new FormFields();
}
});
window.addEventListener('beforeunload', () => window.jvbForm?.cleanup());