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 = `
|
<div class="notification-content">
|
<span class="icon">🔄</span>
|
<span class="message">Resumed: ${this.getStatusMessage(status)}</span>
|
<button type="button" class="dismiss">×</button>
|
</div>
|
`;
|
|
// 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 ? `<button type="button" class="ql-jvb_image">\n ${dashboardSettings.icons.image}\n </button>` : '';
|
toolbar.id = `toolbar-${textarea.id}`;
|
toolbar.innerHTML = `
|
<span class="ql-formats">
|
<button type="button" class="ql-p">
|
${jvbSettings.icons.paragraph}
|
</button>
|
<button type="button" class="ql-h1">
|
${jvbSettings.icons.h1}
|
</button>
|
<button type="button" class="ql-h2">
|
${jvbSettings.icons.h2}
|
</button>
|
<button type="button" class="ql-h3">
|
${jvbSettings.icons.h3}
|
</button>
|
</span>
|
<span class="ql-formats">
|
<button type="button" class="ql-jvb_bold">
|
${jvbSettings.icons['bold']}
|
</button>
|
<button type="button" class="ql-jvb_italic">
|
${jvbSettings.icons['italic']}
|
</button>
|
<button type="button" class="ql-jvb_underline">
|
${jvbSettings.icons['underline']}
|
</button>
|
<button type="button" class="ql-jvb_strike">
|
${jvbSettings.icons['strike']}
|
</button>
|
</span>
|
<span class="ql-formats">
|
<button type="button" class="ql-jvb_list" value="bullet">
|
${jvbSettings.icons['list-bullets']}
|
</button>
|
<button type="button" class="ql-jvb_list" value="ordered">
|
${jvbSettings.icons['list-numbers']}
|
</button>
|
</span>
|
<span class="ql-formats">
|
<button type="button" class="ql-jvb_align" value="left">
|
${jvbSettings.icons['align-left']}
|
</button>
|
<button type="button" class="ql-jvb_align" value="center">
|
${jvbSettings.icons['align-center']}
|
</button>
|
<button type="button" class="ql-jvb_align" value="right">
|
${jvbSettings.icons['align-right']}
|
</button>
|
</span>
|
<span class="ql-formats">
|
<button type="button" class="ql-jvb_link">
|
${jvbSettings.icons.link}
|
</button>
|
${image}
|
</span>
|
`;
|
|
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 = `
|
<div class="quill-link-modal-content ">
|
<label for="link">Enter URL</label>
|
<input type="url" id="link" placeholder="Enter URL" value="${existingLink || ''}" />
|
<div class="buttons">
|
<button type="button" class="save">Save</button>
|
${existingLink ? '<button type="button" class="remove">Remove</button>' : ''}
|
<button type="button" class="cancel">Cancel</button>
|
</div>
|
</div>
|
`;
|
|
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());
|