/**
|
* Enhanced FormController - Manages forms with special fields, caching, and queue integration
|
* Works with DataStore for CRUD operations and standalone for front-end forms
|
*/
|
class FormController {
|
constructor(config = {}) {
|
this.config = {
|
collectFormData: false,
|
... config
|
}
|
this.isRestoring = false;
|
const store = window.jvbStore.register(
|
'forms',
|
{
|
storeName: 'forms',
|
keyPath: 'formId',
|
indexes: [
|
{ name: 'status', keyPath: 'status' },
|
{ name: 'operationId', keyPath: 'operationId' },
|
{ name: 'timestamp', keyPath: 'timestamp' },
|
{ name: 'formType', keyPath: 'type' }
|
],
|
TTL: 7 * 24 * 60 * 1000, //7 days
|
validateData: true,
|
delayFetch: true
|
});
|
this.store = store.forms;
|
|
this.debouncer = window.debouncer;
|
|
this.ignore = [];
|
|
this.populateForm = window.jvbPopulate;
|
|
this.subscribers = new Set();
|
this.forms = new Map();
|
this.specialFields = new Map();
|
this.dependencies = new Map();
|
|
// Validation (YOU ARE GREAT!)
|
this.validators = this.initValidators();
|
this.touchedFields = new Set();
|
|
// Auto-save configuration
|
this.autoSaveDefaults = {
|
delay: 3000, // 3 seconds
|
typingDelay: 1500, // 1.5 seconds for text fields
|
enabled: true
|
};
|
|
// Repeater field management
|
this.activeRepeaters = new Map();
|
this.repeaterDelays = {
|
change: 6000,
|
typing: 3000,
|
blur: 1500,
|
add: 500,
|
remove: 800,
|
reorder: 1000
|
};
|
|
this.isTimeline = window.crudManager && window.crudManager.isTimeline;
|
|
// Bind handlers
|
this.clickHandler = this.handleClick.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
this.submitHandler = this.handleSubmit.bind(this);
|
this.inputHandler = this.handleInput.bind(this);
|
this.blurHandler = this.handleBlur.bind(this);
|
//Processors
|
this.processRepeaterField = this.processRepeaterField.bind(this);
|
this.processGroupField = this.processGroupField.bind(this);
|
this.processLocationField = this.processLocationField.bind(this);
|
this.processRegularField = this.processRegularField.bind(this);
|
|
this.init();
|
}
|
|
async init() {
|
this.store.subscribe(this.handleStoreEvent.bind(this));
|
|
// Set up global form handlers for standalone forms
|
this.initListeners();
|
if (window.jvbQueue) {
|
window.jvbQueue.subscribe((event, data) => {
|
if (event === 'operation-completed' && data.type === 'form') {
|
this.handleOperationComplete(data);
|
}
|
});
|
}
|
}
|
|
/**
|
* Handle operation completion - clear related form cache
|
*/
|
async handleOperationComplete(operation) {
|
// Clear the form data from store
|
if (operation.formId) {
|
try {
|
await this.store.delete(operation.formId);
|
} catch (error) {
|
console.warn('Failed to clear form cache:', error);
|
}
|
}
|
|
// Clear any related form state
|
const form = this.forms.get(operation.formId);
|
if (form) {
|
form.isDirty = false;
|
form.lastSaved = Date.now();
|
form.data = {};
|
}
|
}
|
|
handleStoreEvent(event, data) {
|
switch(event) {
|
case 'item-saved':
|
if (data.item.status === 'autosave') {
|
// this.showFormStatus(data.item.formId, 'autosave');
|
}
|
break;
|
case 'data-loaded':
|
this.checkPendingForms();
|
break;
|
}
|
}
|
|
/**
|
* Check for pending forms from current page
|
*/
|
async checkPendingForms() {
|
const allForms = await this.store.getAll();
|
const currentPath = window.location.pathname;
|
|
const pendingForms = allForms.filter(form => {
|
if (form.status !== 'draft') return false;
|
|
// Check if form is from current page
|
const formPath = form.data?._wp_http_referer;
|
return formPath === currentPath;
|
});
|
|
pendingForms.forEach(item => {
|
const formElement = this.findFormElement(item);
|
if (!formElement) return;
|
|
// Register form if not already registered
|
let formConfig = this.forms.get(item.formId);
|
if (!formElement.dataset.formId) {
|
formConfig = this.registerForm(formElement);
|
}
|
|
// Set flag to prevent event handlers from firing
|
this.isRestoring = true;
|
// Auto-populate the form
|
new this.populateForm(formElement, item.data);
|
|
// Reset flag after a tick (gives DOM time to settle)
|
setTimeout(() => {
|
this.isRestoring = false;
|
}, 0);
|
|
// Show restore status
|
this.showFormStatus(item.formId, 'restored');
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Your previous entry has been restored');
|
}
|
});
|
}
|
|
/**
|
* Find form element that matches the cached data
|
*/
|
findFormElement(formData) {
|
// Try by form_id first (hidden field)
|
if (formData.data?.form_id) {
|
const form = document.querySelector(`[name="form_id"][value="${formData.data.form_id}"]`)?.closest('form');
|
if (form) return form;
|
}
|
|
// Try by form_type
|
if (formData.data?.form_type) {
|
const form = document.querySelector(`[name="form_type"][value="${formData.data.form_type}"]`)?.closest('form');
|
if (form) return form;
|
}
|
|
// Fallback: try by formId (if it was already registered)
|
return document.querySelector(`[data-form-id="${formData.formId}"]`);
|
}
|
|
/**
|
* Show notification for pending changes
|
*/
|
/**
|
* Show notification for pending changes
|
*/
|
showPendingNotification(formId, formData) {
|
const formElement = document.querySelector(`[data-form-id="${formId}"]`);
|
if (!formElement) return;
|
|
const notification = document.createElement('div');
|
notification.className = 'pending-changes-notification';
|
notification.innerHTML = `
|
<p>We noticed unsaved changes from last time. Would you like to restore them?</p>
|
<button class="restore-changes" data-form-id="${formId}">Restore</button>
|
<button class="discard-changes" data-form-id="${formId}">Discard</button>
|
`;
|
|
formElement.insertBefore(notification, formElement.firstChild);
|
|
// Add handlers
|
notification.querySelector('.restore-changes').addEventListener('click', async () => {
|
await this.restorePendingForm(formId, formData);
|
notification.remove();
|
});
|
|
notification.querySelector('.discard-changes').addEventListener('click', async () => {
|
await this.discardPendingForm(formId);
|
notification.remove();
|
});
|
}
|
|
/**
|
* Restore pending form data
|
*/
|
async restorePendingForm(formId, formData) {
|
const form = document.querySelector(`[data-form-id="${formId}"]`);
|
if (!form) return;
|
|
// Populate form with cached data
|
new this.populateForm(form, formData);
|
|
// Update status in store (mark as restored, not draft)
|
await this.store.save({
|
formId: formId,
|
data: formData,
|
status: 'restored',
|
timestamp: Date.now()
|
});
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Previous changes restored');
|
}
|
}
|
|
/**
|
* Discard pending form data
|
*/
|
async discardPendingForm(formId) {
|
try {
|
await this.store.delete(formId);
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Previous changes discarded');
|
}
|
} catch (error) {
|
console.error('Failed to discard pending form:', error);
|
}
|
}
|
|
/**
|
* Setup global handlers for standalone forms
|
*/
|
initListeners() {
|
// Only add if not already added
|
if (!this.globalHandlersAdded) {
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
document.addEventListener('blur', this.blurHandler, true);
|
document.addEventListener('input', this.inputHandler);
|
this.globalHandlersAdded = true;
|
}
|
}
|
|
/**
|
* Register a standalone form (for front-end forms)
|
*/
|
registerForm(formElement, options = {}) {
|
if (!formElement) return;
|
const formId = formElement.dataset.formId || `form_${Date.now()}`;
|
formElement.dataset.formId = formId;
|
|
formElement.addEventListener('submit', this.submitHandler);
|
|
const formConfig = {
|
element: formElement,
|
id: formId,
|
status: '',
|
options: {
|
autosave: 'autosave' in formElement.dataset,
|
autoUpload: true,
|
saveDelay: this.autoSaveDefaults.delay,
|
endpoint: formElement.dataset.save ?? '',
|
formStatus: true,
|
cache: true,
|
...options
|
},
|
dependencies: new Map(),
|
data: this.collectFormData(formElement, true),
|
};
|
|
this.initializeFormFields(formElement, formConfig);
|
this.forms.set(formId, formConfig);
|
|
// Check for pending data - FIXED
|
if (this.store && formConfig.options.cache) {
|
const cached = this.store.get(formId);
|
if (cached && cached.data) {
|
this.showPendingNotification(formId, cached.data);
|
}
|
}
|
|
return formConfig;
|
}
|
|
/**
|
* Initialize all special fields in a form
|
*/
|
initializeFormFields(form, formConfig = null) {
|
// Initialize Quill editors
|
this.initQuillEditors(form);
|
|
// Initialize repeater fields
|
this.initRepeaterFields(form, formConfig);
|
|
this.initTagListFields(form, formConfig);
|
|
// Initialize conditional fields
|
if (formConfig) {
|
this.initConditionalFields(form, formConfig);
|
}
|
|
// Initialize character limits
|
this.initCharacterLimits(form);
|
|
// Initialize image upload fields
|
this.initImageUploadFields(form, formConfig);
|
|
// Initialize tabs if present
|
if (window.jvbTabs && form.querySelector('nav.tabs')) {
|
formConfig.tabs = new window.jvbTabs(form);
|
this.forms.set(formConfig.formId, formConfig);
|
this.initSteppedForm(formConfig.formId);
|
}
|
|
// Scan for existing selector fields
|
if (window.jvbSelector) {
|
window.jvbSelector.scanExistingFields(form);
|
}
|
}
|
|
/**
|
* Initialize stepped form functionality
|
*/
|
initSteppedForm(formId) {
|
const formConfig = this.forms.get(formId);
|
const form = formConfig.element;
|
const tabsInstance = formConfig.tabs;
|
|
const sections = form.querySelectorAll('.tab-content');
|
const totalSteps = sections.length;
|
const progressBar = form.querySelector('.form-progress .fill');
|
const stepText = form.querySelector('.step-text .current');
|
const tabButtons = form.querySelectorAll('nav.tabs button');
|
|
// Update progress display
|
const updateProgress = (currentStep) => {
|
const progress = (currentStep / totalSteps) * 100;
|
if (progressBar) {
|
progressBar.style.width = progress + '%';
|
}
|
if (stepText) {
|
stepText.textContent = currentStep;
|
}
|
|
// Update tab states
|
tabButtons.forEach((btn, idx) => {
|
const stepNum = idx + 1;
|
btn.classList.remove('current', 'completed', 'pending');
|
|
if (stepNum < currentStep) {
|
btn.classList.add('completed');
|
} else if (stepNum === currentStep) {
|
btn.classList.add('current');
|
} else {
|
btn.classList.add('pending');
|
}
|
});
|
};
|
|
// Next/Previous button handling
|
form.addEventListener('click', (e) => {
|
const nextBtn = e.target.closest('[data-action="next-step"]');
|
const prevBtn = e.target.closest('[data-action="prev-step"]');
|
|
if (nextBtn) {
|
e.preventDefault();
|
const currentSection = nextBtn.closest('.tab-content');
|
const currentStep = parseInt(currentSection.dataset.step);
|
const nextSection = form.querySelector(`.tab-content[data-step="${currentStep + 1}"]`);
|
|
if (nextSection && this.validateStep(currentSection)) {
|
const nextTab = nextSection.dataset.tab;
|
tabsInstance.switchTab(nextTab, true);
|
updateProgress(currentStep + 1);
|
|
// Scroll to top of form
|
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
}
|
}
|
|
if (prevBtn) {
|
e.preventDefault();
|
const currentSection = prevBtn.closest('.tab-content');
|
const currentStep = parseInt(currentSection.dataset.step);
|
const prevSection = form.querySelector(`.tab-content[data-step="${currentStep - 1}"]`);
|
|
if (prevSection) {
|
const prevTab = prevSection.dataset.tab;
|
tabsInstance.switchTab(prevTab, true);
|
updateProgress(currentStep - 1);
|
|
// Scroll to top of form
|
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
}
|
}
|
});
|
|
// Update progress when tabs are clicked directly
|
const originalSwitchTab = tabsInstance.switchTab.bind(tabsInstance);
|
tabsInstance.switchTab = (tab, updateHistory) => {
|
originalSwitchTab(tab, updateHistory);
|
const activeSection = form.querySelector(`.tab-content[data-tab="${tab}"]`);
|
if (activeSection) {
|
const step = parseInt(activeSection.dataset.step);
|
updateProgress(step);
|
}
|
};
|
|
// Initialize progress
|
updateProgress(1);
|
}
|
|
/**
|
* Validate current step before allowing progression
|
* Can be enhanced with custom validation rules
|
*/
|
validateStep(section) {
|
const fields = section.querySelectorAll('.field');
|
let allValid = true;
|
|
fields.forEach(fieldWrapper => {
|
const input = fieldWrapper.querySelector('input, textarea, select');
|
if (input && !input.closest('[hidden]')) {
|
const isValid = this.validateField(input, fieldWrapper);
|
if (!isValid) {
|
allValid = false;
|
}
|
}
|
});
|
|
return allValid;
|
}
|
|
|
/**
|
* Initialize Quill editors
|
*/
|
initQuillEditors(form) {
|
window.jvbQuill(form);
|
}
|
|
/**
|
* Initialize repeater fields
|
*/
|
initRepeaterFields(form, formConfig) {
|
form.querySelectorAll('.repeater').forEach(repeater => {
|
const addButton = repeater.querySelector('.add-repeater-row');
|
const container = repeater.querySelector('.repeater-items');
|
const template = repeater.querySelector('template');
|
|
if (!addButton || !template || !container) return;
|
|
// Initialize Sortable for drag-and-drop
|
if (window.Sortable) {
|
new Sortable(container, {
|
handle: '.repeater-row-header',
|
animation: 150,
|
onEnd: () => {
|
this.updateRepeaterOrder(repeater, formConfig);
|
}
|
});
|
}
|
|
// Add row handler
|
addButton.addEventListener('click', () => {
|
this.addRepeaterRow(repeater, formConfig);
|
});
|
|
// Remove row handlers
|
container.addEventListener('click', (e) => {
|
if (e.target.closest('.remove-row')) {
|
this.removeRepeaterRow(e.target.closest('.repeater-row'), formConfig);
|
}
|
});
|
});
|
}
|
|
/**
|
* Add repeater row
|
*/
|
addRepeaterRow(repeater, formConfig) {
|
const container = repeater.querySelector('.repeater-items');
|
const template = repeater.querySelector('template');
|
const index = container.children.length;
|
const fieldName = repeater.dataset.field;
|
|
// Clone template
|
const row = template.content.cloneNode(true).firstElementChild;
|
row.dataset.index = index;
|
|
// Update field names
|
row.querySelectorAll('input, select, textarea').forEach(field => {
|
const originalName = field.name;
|
field.name = `${fieldName}:${index}:${originalName}`;
|
field.id = `${fieldName}-${index}-${originalName}`;
|
|
// Update label if exists
|
const label = field.nextElementSibling;
|
if (label && label.tagName === 'LABEL') {
|
label.htmlFor = field.id;
|
}
|
});
|
|
container.appendChild(row);
|
|
if (formConfig) {
|
this.scheduleSave(formConfig, {
|
type: 'repeater',
|
action: 'add',
|
fieldName: fieldName,
|
delay: this.repeaterDelays.add
|
});
|
}
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Row added');
|
}
|
}
|
|
/**
|
* Remove repeater row
|
*/
|
removeRepeaterRow(row, formConfig) {
|
const repeater = row.closest('.repeater');
|
const fieldName = repeater.dataset.field;
|
|
row.remove();
|
|
// Reindex remaining rows
|
this.updateRepeaterOrder(repeater, formConfig);
|
|
// Schedule save
|
if (formConfig) {
|
this.scheduleSave(formConfig, {
|
type: 'repeater',
|
action: 'remove',
|
fieldName: fieldName,
|
delay: this.repeaterDelays.remove
|
});
|
}
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Row removed');
|
}
|
}
|
|
/**
|
* Update repeater order after sorting
|
*/
|
updateRepeaterOrder(repeater, formConfig) {
|
const container = repeater.querySelector('.repeater-items');
|
const fieldName = repeater.dataset.field;
|
|
// Reindex all rows
|
Array.from(container.children).forEach((row, index) => {
|
row.dataset.index = index;
|
|
// Update field names
|
row.querySelectorAll('input, select, textarea').forEach(field => {
|
const parts = field.name.split(':');
|
if (parts.length === 3) {
|
const originalName = parts[2];
|
field.name = `${fieldName}:${index}:${originalName}`;
|
field.id = `${fieldName}-${index}-${originalName}`;
|
|
// Update label
|
const label = field.nextElementSibling;
|
if (label && label.tagName === 'LABEL') {
|
label.htmlFor = field.id;
|
}
|
}
|
});
|
});
|
|
// Schedule save
|
if (formConfig) {
|
this.scheduleSave(formConfig, {
|
type: 'repeater',
|
action: 'reorder',
|
fieldName: fieldName,
|
delay: this.repeaterDelays.reorder
|
});
|
}
|
}
|
|
/**
|
* Initialize tag list fields
|
*/
|
initTagListFields(form, formConfig) {
|
form.querySelectorAll('.field.tag-list').forEach(field => {
|
const inputRow = field.querySelector('.tag-input-row');
|
const addButton = field.querySelector('.add-tag-item');
|
const tagsContainer = field.querySelector('.tag-items');
|
const template = field.querySelector('.tag-template');
|
const fieldName = field.dataset.field;
|
const tagFormat = field.dataset.tagFormat || 'first_field';
|
|
if (!inputRow || !addButton || !tagsContainer || !template) return;
|
|
// Get all input fields in the input row (excluding the button)
|
const getInputFields = () => {
|
return Array.from(inputRow.querySelectorAll('input, select, textarea'))
|
.filter(input => !input.closest('button'));
|
};
|
|
// Add tag handler
|
const addTag = () => {
|
const inputs = getInputFields();
|
const data = {};
|
let hasValue = false;
|
|
// Collect values from inputs
|
inputs.forEach(input => {
|
const fieldName = input.name.replace('new_', '');
|
const value = this.getFieldValue(input);
|
|
if (value) hasValue = true;
|
data[fieldName] = value;
|
});
|
|
if (!hasValue) {
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Please fill in at least one field', 'error');
|
}
|
inputs[0].focus();
|
return;
|
}
|
|
// Validate required fields using data-required attribute
|
const invalidField = inputs.find(input => {
|
const isRequired = ('required' in input.dataset && input.dataset.required === '1');
|
const value = this.getFieldValue(input);
|
return isRequired && !value;
|
});
|
|
if (invalidField) {
|
const fieldWrapper = invalidField.closest('.field');
|
const fieldLabel = fieldWrapper?.querySelector('label')?.textContent || 'This field';
|
this.showError(fieldWrapper, `${fieldLabel} is required.`);
|
|
invalidField.focus();
|
return;
|
}
|
|
for (let input of inputs) {
|
let wrapper = field.closest('.field');
|
if (!this.validateField(input, wrapper)){
|
input.focus();
|
return;
|
}
|
}
|
|
// Clone template and populate
|
const index = tagsContainer.children.length;
|
const newTag = template.content.cloneNode(true).firstElementChild;
|
newTag.dataset.index = index;
|
|
// Update tag label
|
const tagLabel = newTag.querySelector('.tag-label');
|
if (tagLabel) {
|
tagLabel.textContent = this.getTagDisplayText(data, tagFormat);
|
}
|
|
// Update hidden inputs
|
newTag.querySelectorAll('input[type="hidden"]').forEach(input => {
|
const fieldKey = input.dataset.field;
|
input.name = `${fieldName}:${index}:${fieldKey}`;
|
input.value = data[fieldKey] || '';
|
});
|
|
tagsContainer.appendChild(newTag);
|
|
// Clear inputs
|
inputs.forEach(input => {
|
if (input.type === 'checkbox' || input.type === 'radio') {
|
input.checked = false;
|
} else {
|
input.value = '';
|
}
|
let field = input.closest('.field');
|
this.clearValidation(field);
|
});
|
|
// Focus first input
|
if (inputs.length > 0) {
|
inputs[0].focus();
|
}
|
|
|
// Schedule save
|
if (formConfig) {
|
this.scheduleSave(formConfig, {
|
type: 'tag_list',
|
action: 'add',
|
fieldName: fieldName,
|
delay: this.autoSaveDefaults.delay
|
});
|
}
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Item added');
|
}
|
};
|
|
// Add button click
|
addButton.addEventListener('click', addTag);
|
|
// Enter key support on last input
|
const inputs = getInputFields();
|
if (inputs.length > 0) {
|
// Tab through inputs, Enter on last one adds the tag
|
inputs[inputs.length - 1].addEventListener('keypress', (e) => {
|
if (e.key === 'Enter') {
|
e.preventDefault();
|
addTag();
|
}
|
});
|
|
// Enter on other inputs moves to next field
|
inputs.slice(0, -1).forEach((input, i) => {
|
input.addEventListener('keypress', (e) => {
|
if (e.key === 'Enter') {
|
e.preventDefault();
|
inputs[i + 1].focus();
|
}
|
});
|
});
|
}
|
|
// Remove tag handler
|
tagsContainer.addEventListener('click', (e) => {
|
if (e.target.closest('.remove-tag')) {
|
const tag = e.target.closest('.tag-item');
|
const tagText = tag.querySelector('.tag-label')?.textContent || 'Item';
|
|
tag.remove();
|
|
// Reindex remaining tags
|
this.reindexTagList(tagsContainer, fieldName);
|
|
// Schedule save
|
if (formConfig) {
|
this.scheduleSave(formConfig, {
|
type: 'tag_list',
|
action: 'remove',
|
fieldName: fieldName,
|
delay: this.autoSaveDefaults.delay
|
});
|
}
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce(`${tagText} removed`);
|
}
|
}
|
});
|
});
|
}
|
|
/**
|
* Reindex tag list items
|
*/
|
reindexTagList(container, baseFieldName) {
|
Array.from(container.children).forEach((tag, index) => {
|
tag.dataset.index = index;
|
|
tag.querySelectorAll('input[type="hidden"]').forEach(input => {
|
const fieldKey = input.dataset.field;
|
input.name = `${baseFieldName}:${index}:${fieldKey}`;
|
});
|
});
|
}
|
|
/**
|
* Get display text for tag based on format
|
*/
|
getTagDisplayText(data, format) {
|
const values = Object.values(data).filter(v => v);
|
|
if (values.length === 0) return 'New Item';
|
|
switch (format) {
|
case 'first_field':
|
return values[0];
|
|
case 'all_fields':
|
return values.join(', ');
|
|
default:
|
// Template format like "{name} ({email})"
|
if (format.includes('{')) {
|
let text = format;
|
for (const [key, value] of Object.entries(data)) {
|
text = text.replace(`{${key}}`, value);
|
}
|
return text;
|
}
|
// Use specific field
|
return data[format] || values[0];
|
}
|
}
|
|
/**
|
* HTML escape helper
|
*/
|
escapeHtml(text) {
|
const div = document.createElement('div');
|
div.textContent = text;
|
return div.innerHTML;
|
}
|
|
/**
|
* Initialize conditional fields
|
*/
|
initConditionalFields(form, formConfig) {
|
form.querySelectorAll('[data-depends-on]').forEach(field => {
|
const dependsOn = field.dataset.dependsOn;
|
const requiredValue = field.dataset.dependsValue;
|
const operator = field.dataset.dependsOperator || '==';
|
|
// Store dependency
|
if (!formConfig.dependencies.has(dependsOn)) {
|
formConfig.dependencies.set(dependsOn, []);
|
}
|
formConfig.dependencies.get(dependsOn).push({
|
field: field,
|
requiredValue: requiredValue,
|
operator: operator
|
});
|
|
// Check initial state
|
this.checkFieldDependency(form, field, dependsOn, requiredValue, operator);
|
});
|
}
|
|
/**
|
* Check field dependency
|
*/
|
checkFieldDependency(form, field, dependsOn, requiredValue, operator) {
|
const triggerField = form.querySelector(`[name="${dependsOn}"]`);
|
if (!triggerField) return;
|
|
const value = this.getFieldValue(triggerField);
|
const shouldShow = this.evaluateCondition(value, requiredValue, operator);
|
|
this.toggleFieldVisibility(field, shouldShow);
|
}
|
|
/**
|
* Evaluate conditional operator
|
*/
|
evaluateCondition(value, requiredValue, operator) {
|
const fieldStr = String(value || '');
|
const requiredStr = String(requiredValue || '');
|
|
switch (operator) {
|
case '==': return fieldStr === requiredStr;
|
case '!=': return fieldStr !== requiredStr;
|
case '>': return parseFloat(fieldStr) > parseFloat(requiredStr);
|
case '<': return parseFloat(fieldStr) < parseFloat(requiredStr);
|
case '>=': return parseFloat(fieldStr) >= parseFloat(requiredStr);
|
case '<=': return parseFloat(fieldStr) <= parseFloat(requiredStr);
|
case 'contains': return fieldStr.includes(requiredStr);
|
case 'empty': return fieldStr === '';
|
case 'not_empty': return fieldStr !== '';
|
default: return fieldStr === requiredStr;
|
}
|
}
|
|
/**
|
* Toggle field visibility
|
*/
|
toggleFieldVisibility(field, show) {
|
const wrapper = field.closest('.field, fieldset');
|
if (!wrapper) return;
|
|
wrapper.hidden = !show;
|
wrapper.querySelectorAll('input, select, textarea').forEach(control => {
|
control.disabled = !show;
|
if (!show && control.hasAttribute('required')) {
|
control.dataset.wasRequired = 'true';
|
control.removeAttribute('required');
|
} else if (show && control.dataset.wasRequired === 'true') {
|
control.setAttribute('required', '');
|
delete control.dataset.wasRequired;
|
}
|
});
|
}
|
|
/**
|
* Initialize character limits
|
*/
|
initCharacterLimits(form) {
|
form.querySelectorAll('[data-limit]').forEach(input => {
|
const limit = parseInt(input.dataset.limit, 10);
|
const field = input.closest('.field');
|
|
// Create counter if it doesn't exist
|
let counter = field?.querySelector('.char-count');
|
if (!counter && field) {
|
counter = document.createElement('div');
|
counter.className = 'char-count';
|
counter.innerHTML = `<span class="current">0</span> / <span class="limit">${limit}</span>`;
|
field.appendChild(counter);
|
}
|
|
const updateCount = () => {
|
const length = input.value.length;
|
if (counter) {
|
counter.querySelector('.current').textContent = length;
|
counter.classList.toggle('exceeded', length > limit);
|
}
|
|
// Truncate if exceeds limit
|
if (length > limit) {
|
input.value = input.value.substring(0, limit);
|
if (counter) {
|
counter.querySelector('.current').textContent = limit;
|
}
|
}
|
};
|
|
input.addEventListener('input', updateCount);
|
updateCount(); // Initial count
|
});
|
}
|
|
/**
|
* Initialize image upload fields
|
*/
|
initImageUploadFields(form, config) {
|
window.jvbUploads.scanFields(form, config.options.autoUpload);
|
}
|
|
/* ========== Event Handlers ========== */
|
|
async handleSubmit(event) {
|
const form = event.target;
|
|
if (!form.dataset.formId) return;
|
const formConfig = this.forms.get(form.dataset.formId);
|
|
// Handle subscriber-based forms
|
if (this.subscribers.size > 0) {
|
event.preventDefault();
|
const formData = this.collectFormData(form);
|
|
// Notify subscribers (they'll handle actual submission)
|
this.notify('form-submit', {
|
formId: form.dataset.formId,
|
fullData: formData,
|
config: formConfig
|
});
|
}
|
}
|
|
handleFormSuccess(form, data) {
|
// Clear previous errors
|
form.querySelectorAll('.error-message').forEach(el => el.remove());
|
form.querySelectorAll('.field-error').forEach(el =>
|
el.classList.remove('field-error')
|
);
|
|
// Add success class to form
|
form.classList.add('form-success');
|
|
// Show success message if provided
|
if (data.message) {
|
const success = document.createElement('div');
|
success.className = 'form-success-message success-message';
|
success.textContent = data.message;
|
form.insertBefore(success, form.firstChild);
|
|
const icon = window.getIcon?.('check-circle');
|
if (icon) {
|
icon.classList.add('success-icon');
|
success.prepend(icon);
|
}
|
}
|
|
// If there's a title/description (for registration success)
|
if (data.title || data.description) {
|
const successBox = document.createElement('div');
|
successBox.className = 'success-box';
|
|
if (data.title) {
|
const title = document.createElement('h3');
|
title.textContent = data.title;
|
successBox.appendChild(title);
|
}
|
|
if (data.description) {
|
const descriptions = Array.isArray(data.description)
|
? data.description
|
: [data.description];
|
|
descriptions.forEach(desc => {
|
const p = document.createElement('p');
|
p.textContent = desc;
|
successBox.appendChild(p);
|
});
|
}
|
|
form.insertBefore(successBox, form.firstChild);
|
}
|
|
// DELETE CACHED FORM DATA ON SUCCESS
|
if (form.dataset.formId) {
|
this.store.delete(form.dataset.formId).catch(err => {
|
console.warn('Failed to clear form cache:', err);
|
});
|
|
// Clear form config dirty state
|
const formConfig = this.forms.get(form.dataset.formId);
|
if (formConfig) {
|
formConfig.isDirty = false;
|
formConfig.lastSaved = Date.now();
|
formConfig.data = {}; // Clear cached data
|
}
|
}
|
|
// Announce success for accessibility
|
if (window.jvbA11y) {
|
window.jvbA11y.announce(data.message || 'Form submitted successfully');
|
}
|
|
// Trigger custom event
|
form.dispatchEvent(new CustomEvent('jvb-form-success', {
|
detail: data
|
}));
|
}
|
|
handleFormError(form, data) {
|
// Clear all previous errors
|
form.querySelectorAll('.error-message').forEach(el => el.remove());
|
form.querySelectorAll('.field-error, .has-error').forEach(el => {
|
el.classList.remove('field-error', 'has-error');
|
});
|
|
// Clear validation states using existing method
|
form.querySelectorAll('.field').forEach(fieldWrapper => {
|
this.clearValidation(fieldWrapper);
|
});
|
|
// Handle field-specific errors
|
if (data.field) {
|
const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`);
|
if (fieldWrapper) {
|
// Use existing showError method for consistency
|
this.showError(fieldWrapper, data.message);
|
|
// Mark as touched so validation persists
|
this.touchedFields.add(data.field);
|
|
// Scroll to error
|
fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
// Focus the input for better UX
|
const input = fieldWrapper.querySelector('input, textarea, select');
|
if (input) {
|
input.focus();
|
}
|
}
|
} else {
|
// General form error (not field-specific)
|
const error = document.createElement('div');
|
error.className = 'form-error error-message';
|
error.textContent = data.message;
|
|
// Add icon for consistency
|
const icon = window.getIcon?.('close-circle');
|
if (icon) {
|
icon.classList.add('error-icon');
|
error.prepend(icon);
|
}
|
|
form.insertBefore(error, form.firstChild);
|
|
// Scroll to top to show the error
|
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
}
|
|
// Announce error for accessibility
|
if (window.jvbA11y) {
|
const announcement = data.field
|
? `Error in ${data.field}: ${data.message}`
|
: `Form error: ${data.message}`;
|
window.jvbA11y.announce(announcement);
|
}
|
|
// Trigger custom event
|
form.dispatchEvent(new CustomEvent('jvb-form-error', {
|
detail: data
|
}));
|
}
|
|
handleClick(e) {
|
if (window.targetCheck(e, 'div.quantity')) {
|
let container = window.targetCheck(e, 'div.quantity');
|
this.handleNumberClick(e, container.querySelector('input'));
|
} else if (window.targetCheck(e, '[data-action]')) {
|
let actionEl = window.targetCheck(e, '[data-action]');
|
let action = actionEl.dataset.action;
|
let form = actionEl.closest('form');
|
|
switch (action) {
|
case 'clear-form':
|
if (form?.dataset.formId) {
|
this.store.delete(form.dataset.formId);
|
form.reset();
|
// Hide the status message
|
form.querySelector('.fstatus').hidden = true;
|
}
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Form cleared, starting fresh');
|
}
|
break;
|
|
case 'dismiss-restore':
|
form.querySelector('.fstatus').hidden = true;
|
break;
|
}
|
}
|
}
|
|
|
handleNumberClick(e, input) {
|
let change = 0;
|
|
if (e.target.closest('.increase')) {
|
change += 1;
|
} else if (e.target.closest('.decrease')) {
|
change -=1;
|
}
|
if (change !== 0) {
|
let step = parseFloat(input.step);
|
//Allow for cents, but default to increasing by 1
|
step = Math.max(step, 1);
|
|
if(e.ctrlKey && e.shiftKey) {
|
step = step * 50;
|
} else if (e.ctrlKey) {
|
step = step * 5;
|
} else if (e.shiftKey) {
|
step = step * 10;
|
}
|
|
let value = (input.value === '') ? 0 : parseFloat(input.value);
|
|
input.value = (value + (step * change));
|
this.handleNumberLimits(input);
|
}
|
}
|
|
handleNumberLimits(input) {
|
let [
|
min,
|
max,
|
increase,
|
decrease
|
] = [
|
input.min,
|
input.max,
|
input.closest('.quantity')?.querySelector('.increase'),
|
input.closest('.quantity')?.querySelector('.decrease')
|
];
|
let value = parseFloat(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;
|
}
|
}
|
|
handleChange(event) {
|
if (event.target.closest('[data-ignore]') || this.isRestoring) {
|
return;
|
}
|
const target = event.target;
|
const form = target.form || target.closest('form');if (!form) return;
|
|
const formConfig = this.forms?.get(form.dataset.formId);
|
if (!formConfig) return;
|
|
if (formConfig.options.autosave || this.subscribers.size > 0) {
|
// Check conditional fields
|
const dependencies = formConfig.dependencies.get(target.name);
|
if (dependencies) {
|
dependencies.forEach(dep => {
|
this.checkFieldDependency(form, dep.field, target.name, dep.requiredValue, dep.operator);
|
});
|
}
|
|
// Schedule auto-save if enabled
|
const delay = this.getDelayForField(target);
|
this.scheduleSave(formConfig, delay);
|
}
|
}
|
|
handleBlur(e) {
|
if (e.target.closest('[data-ignore]') || this.isRestoring) {
|
return;
|
}
|
const target = e.target;
|
const form = target.form || target.closest('form');
|
|
if (!form) return;
|
|
|
const input = e.target.closest('input, textarea, select');
|
if (input) {
|
const fieldWrapper = this.findFieldWrapper(input);
|
if (fieldWrapper) {
|
// Mark as touched and validate
|
const fieldName = fieldWrapper.dataset.field;
|
if (fieldName) {
|
if (this.shouldDebounce(input)) {
|
window.debouncer.cancel(`validate_${fieldName}`);
|
}
|
this.touchedFields.add(fieldName);
|
}
|
this.validateField(input, fieldWrapper);
|
}
|
const formConfig = this.forms?.get(form.dataset.formId);
|
if (formConfig) {
|
// Shorter delay on blur
|
this.scheduleSave(formConfig, {
|
type: 'blur',
|
fieldName: target.name,
|
delay: 1500
|
});
|
}
|
}
|
}
|
|
handleInput(e) {
|
if (e.target.closest('[data-ignore]') || ! e.target.closest('form') || this.isRestoring) {
|
return;
|
}
|
const input = e.target.closest('input, textarea, select');
|
if (!input) return;
|
|
let form = input.closest('form');
|
this.showFormStatus(form.dataset.formId, 'pending');
|
|
const fieldWrapper = this.findFieldWrapper(input);
|
if (!fieldWrapper) return;
|
|
const fieldName = fieldWrapper.dataset.field;
|
if (fieldName) {
|
this.touchedFields.add(fieldName);
|
}
|
|
if (this.shouldDebounce(input)){
|
window.debouncer.schedule(
|
`validate_${fieldName}`,
|
() => this.validateField.bind(this),
|
500
|
)
|
}
|
}
|
|
/***************************************************************
|
FORM VALIDATION
|
***************************************************************/
|
/**
|
* Initialize validation rules
|
*/
|
initValidators() {
|
return {
|
email: {
|
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
message: 'Please enter a valid email address'
|
},
|
url: {
|
pattern: /^https?:\/\/.+\..+/,
|
message: 'Please enter a valid URL starting with https://'
|
},
|
phone: {
|
pattern: /^[\d\s\-+().]+$/,
|
message: 'Please enter a valid phone number'
|
},
|
number: {
|
test: (value, fieldWrapper) => {
|
const num = parseFloat(value);
|
if (isNaN(num)) return 'Please enter a valid number';
|
|
const min = fieldWrapper.dataset.min;
|
const max = fieldWrapper.dataset.max;
|
|
if (min !== undefined && num < parseFloat(min)) {
|
return `Value must be at least ${min}`;
|
}
|
if (max !== undefined && num > parseFloat(max)) {
|
return `Value must be at most ${max}`;
|
}
|
return true;
|
}
|
},
|
text: {
|
test: (value, fieldWrapper) => {
|
const minLength = fieldWrapper.dataset.minlength;
|
const maxLength = fieldWrapper.dataset.maxlength;
|
|
if (minLength && value.length < parseInt(minLength)) {
|
return `Must be at least ${minLength} characters`;
|
}
|
if (maxLength && value.length > parseInt(maxLength)) {
|
return `Must be no more than ${maxLength} characters`;
|
}
|
return true;
|
}
|
}
|
};
|
}
|
/**
|
* Find the field wrapper (handles both simple and complex fields)
|
*/
|
findFieldWrapper(input) {
|
// Try to find the closest .field wrapper
|
let wrapper = input.closest('.field');
|
|
// If we're in a repeater row, make sure we get the right field wrapper
|
if (!wrapper) {
|
wrapper = input.closest('[data-field]');
|
}
|
|
return wrapper;
|
}
|
|
/**
|
* Check if input should be debounced
|
*/
|
shouldDebounce(input) {
|
const debounceTypes = ['text', 'email', 'url', 'tel', 'search'];
|
return debounceTypes.includes(input.type) || input.tagName === 'TEXTAREA';
|
}
|
|
/**
|
* Validate a single field
|
*/
|
validateField(input, fieldWrapper) {
|
const value = this.getFieldValue(input);
|
const fieldName = fieldWrapper.dataset.field;
|
|
// Skip validation if field hasn't been touched yet (unless it's required)
|
if (!this.touchedFields.has(fieldName) && !input.required) {
|
return true;
|
}
|
|
// Skip validation if field is empty and not required
|
if (!value && !input.required) {
|
this.clearValidation(fieldWrapper);
|
return true;
|
}
|
|
// Check required
|
if (input.required && !value) {
|
this.showError(fieldWrapper, 'This field is required');
|
return false;
|
}
|
|
// Check HTML5 validity first
|
if (input.checkValidity && !input.checkValidity()) {
|
this.showError(fieldWrapper, input.validationMessage);
|
return false;
|
}
|
|
// Custom pattern validation from data attribute
|
const pattern = fieldWrapper.dataset.pattern;
|
if (pattern && value) {
|
const regex = new RegExp(pattern);
|
if (!regex.test(value)) {
|
const message = fieldWrapper.dataset.validationMessage || 'Invalid format';
|
this.showError(fieldWrapper, message);
|
return false;
|
}
|
}
|
|
// Type-specific validation
|
const validateType = fieldWrapper.dataset.validate || input.type;
|
if (validateType && this.validators[validateType]) {
|
const validator = this.validators[validateType];
|
|
if (validator.pattern && !validator.pattern.test(value)) {
|
this.showError(fieldWrapper, validator.message);
|
return false;
|
}
|
|
if (validator.test) {
|
const result = validator.test(value, fieldWrapper);
|
if (result !== true) {
|
this.showError(fieldWrapper, result);
|
return false;
|
}
|
}
|
}
|
|
// All validations passed
|
this.showSuccess(fieldWrapper);
|
this.notify('field-validated', input);
|
return true;
|
}
|
|
|
|
/**
|
* Show success state (green checkmark)
|
*/
|
showSuccess(fieldWrapper, textMessage = '') {
|
if (!fieldWrapper) return;
|
|
// Find validation elements (they might be in field-input-wrapper or field-content)
|
const success = fieldWrapper.querySelector('.validation-icon.success');
|
const error = fieldWrapper.querySelector('.validation-icon.error');
|
const message = fieldWrapper.querySelector('.validation-message');
|
const input = fieldWrapper.querySelector('input, textarea, select');
|
|
// Remove error state
|
fieldWrapper.classList.remove('has-error');
|
input?.classList.remove('error');
|
|
// Add success state
|
fieldWrapper.classList.add('has-success');
|
|
// Show checkmark (if element exists)
|
if (success) {
|
success.hidden = false;
|
}
|
if (error) {
|
error.hidden = true;
|
}
|
|
// Hide error message
|
if (message) {
|
if (textMessage === '') {
|
message.hidden = true;
|
message.textContent = '';
|
} else {
|
message.hidden = false;
|
message.textContent = textMessage;
|
}
|
}
|
}
|
|
/**
|
* Show error state (red message below field)
|
*/
|
showError(fieldWrapper, errorMessage) {
|
if (!fieldWrapper) return;
|
|
const success = fieldWrapper.querySelector('.validation-icon.success');
|
const error = fieldWrapper.querySelector('.validation-icon.error');
|
const message = fieldWrapper.querySelector('.validation-message');
|
const input = fieldWrapper.querySelector('input, textarea, select');
|
|
// Remove success state
|
fieldWrapper.classList.remove('has-success');
|
|
// Add error state
|
fieldWrapper.classList.add('has-error');
|
input?.classList.add('error');
|
|
// Hide checkmark (if element exists)
|
if (success) {
|
success.hidden = true;
|
}
|
//show x
|
if (error) {
|
error.hidden = false;
|
}
|
|
// Show error message
|
if (message) {
|
message.hidden = false;
|
message.textContent = errorMessage;
|
}
|
}
|
|
/**
|
* Clear validation state
|
*/
|
clearValidation(fieldWrapper) {
|
if (!fieldWrapper) return;
|
|
const icon = fieldWrapper.querySelector('.validation-icon');
|
const message = fieldWrapper.querySelector('.validation-message');
|
const input = fieldWrapper.querySelector('input, textarea, select');
|
|
fieldWrapper.classList.remove('has-error', 'has-success');
|
input?.classList.remove('error');
|
|
if (icon) {
|
icon.hidden = true;
|
}
|
|
if (message) {
|
message.hidden = true;
|
message.textContent = '';
|
}
|
}
|
|
/* ========== Auto-save functionality ========== */
|
/**
|
* Get appropriate delay based on field type and context
|
*/
|
getDelayForField(field) {
|
// Text fields get longer delay for typing
|
if (field.type === 'text' || field.type === 'textarea') {
|
return this.autoSaveDefaults.typingDelay;
|
}
|
|
// Checkboxes, radios, selects get shorter delay
|
if (['checkbox', 'radio', 'select-one', 'select-multiple'].includes(field.type)) {
|
return 1000;
|
}
|
|
// Default delay
|
return this.autoSaveDefaults.delay;
|
}
|
scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
|
if (!formConfig.options.autosave) {
|
return;
|
}
|
document.addEventListener('input', this.saveCheck, {passive: true});
|
const saveKey = `autosave_${formConfig.id}`;
|
|
this.debouncer.schedule(
|
saveKey,
|
() => this.autosave(formConfig),
|
delay
|
);
|
}
|
|
//Extend delay if user is currently typing
|
saveCheck(e) {
|
let form = e.target.closest('form[data-id]');
|
if (!form) {
|
return;
|
}
|
|
this.scheduleSave(this.forms.get(form.dataset.id));
|
}
|
|
async autosave(formConfig) {
|
const formData = this.collectFormData(formConfig.element);
|
|
this.showFormStatus(formConfig.id, 'saving');
|
|
// DataStore will now automatically:
|
// - Convert Sets/Maps to Arrays/Objects
|
// - Strip DOM references
|
// - Validate serializability
|
await this.store.save({
|
formId: formConfig.id,
|
data: formData,
|
status: 'draft',
|
timestamp: Date.now()
|
}).then(() => {
|
this.showFormStatus(formConfig.id, 'autosaved');
|
}).catch(error => {
|
console.error('Autosave failed:', error);
|
this.showFormStatus(formConfig.id, 'error', 'Failed to save changes');
|
});
|
|
// Get only changed fields
|
const changes = this.getChangedFields(formConfig.data, formData);
|
if (Object.keys(changes).length === 0) return;
|
|
// Update stored data
|
formConfig.data = formData;
|
this.forms.set(formConfig.id, formConfig);
|
document.removeEventListener('input', this.handleInput);
|
|
for (let [key, value] of Object.entries(formData)) {
|
// Complex fields need full data
|
if (typeof value === 'object') {
|
changes[key] = value;
|
}
|
}
|
|
// Notify
|
this.notify('form-autosave', {
|
formId: formConfig.id,
|
changes: changes,
|
fullData: formData,
|
config: formConfig
|
});
|
}
|
|
/**
|
* Check if form has unsaved changes
|
*/
|
hasUnsavedChanges(formId) {
|
const formConfig = this.forms.get(formId);
|
if (!formConfig) return false;
|
|
// Check if there are pending operations
|
if (formConfig.operations?.size > 0) return true;
|
|
// Check if current data differs from snapshot
|
const currentData = this.collectFormData(formConfig.element);
|
const changes = this.getChangedFields(formConfig.data, currentData);
|
|
return Object.keys(changes).length > 0;
|
}
|
|
showFormStatus(formID, status, message='') {
|
let form = this.forms.get(formID);
|
if (!form?.options.formStatus) {
|
return;
|
}
|
|
if (form.status === status){
|
return;
|
}
|
|
form.status = status;
|
|
const statusWrap = form.element.querySelector('.fstatus');
|
statusWrap.hidden = false;
|
const statusElement = statusWrap.querySelector('.message');
|
statusElement.textContent = '';
|
statusWrap.querySelector('.icon')?.remove();
|
statusWrap.querySelector('.actions')?.remove(); // Clear old actions
|
|
const messages = {
|
'saving': 'Saving changes...',
|
'autosaved': 'Changes saved locally. Submit form to send to server.',
|
'uploading': 'Uploading your form to server',
|
'submitted': 'Successfully sent to server',
|
'pending': 'Unsaved changes',
|
'restored': 'Welcome back! We\'ve restored your previous entry.',
|
'error': 'Failed to save changes. Refresh and try again?',
|
'offline': 'Changes will be saved when online'
|
};
|
|
const icons = {
|
'autosaved': 'check-circle',
|
'submitted': 'check-circle',
|
'restored': 'history',
|
'error': 'close-circle',
|
'offline': 'cloud-slash',
|
'pending': 'exclamation-mark'
|
}
|
|
let icon = window.getIcon(icons[status]);
|
if (icon) {
|
statusWrap.prepend(icon);
|
}
|
|
if (message === '') {
|
message = messages[status] || status;
|
}
|
statusElement.textContent = message;
|
statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
|
|
// Add action buttons for certain statuses
|
if (status === 'restored') {
|
const actions = document.createElement('div');
|
actions.className = 'actions';
|
actions.innerHTML = `
|
<button type="button" class="button button-small" data-action="dismiss-restore">Got it</button>
|
<button type="button" class="button button-small button-link" data-action="clear-form">Start over</button>
|
`;
|
statusWrap.appendChild(actions);
|
|
// Auto-dismiss after 10 seconds
|
setTimeout(() => statusWrap.hidden = true, 10000);
|
}
|
|
// Auto-hide success messages
|
if (status === 'submitted') {
|
setTimeout(() => statusWrap.hidden = true, 3000);
|
}
|
}
|
|
cleanupSpecialFields() {
|
this.specialFields.forEach(field => {
|
if (field.type === 'quill' && field.instance) {
|
// Remove Quill toolbar
|
const toolbar = field.instance.container.previousSibling;
|
if (toolbar?.classList.contains('ql-toolbar')) {
|
toolbar.remove();
|
}
|
}
|
});
|
|
this.uploader?.destroy();
|
|
this.specialFields.clear();
|
}
|
|
/* ========== Form Data Methods ========== */
|
|
collectFormData(form) {
|
if (Object.hasOwn(form.dataset, 'timeline')) {
|
return this.collectTimeline(form);
|
}
|
//Table forms are handled separately
|
if (form.classList.contains('table') && form.tagName === 'FORM') {
|
return {};
|
}
|
|
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 (Object.keys(postData).length !== 0) {
|
data = this.mergeRepeaterData(data, repeaterData);
|
return this.mergePostData(data, postData);
|
}
|
return this.mergeRepeaterData(data, repeaterData);
|
}
|
|
collectTimeline(form) {
|
let data = {};
|
let posts = {}; // Temporary object keyed by post ID
|
let postOrder = []; // Track order as encountered (preserves DOM/drag order)
|
let formData = new FormData(form);
|
|
for (const [key, value] of formData.entries()) {
|
if (this.ignore.includes(key) || key.endsWith('_temp')) {
|
continue;
|
}
|
const match = key.match(/^\[(\d+)](.+)$/);
|
if (match) {
|
// Timeline-specific field: [postId]fieldName
|
const [, postId, fieldName] = match;
|
if (!posts[postId]) {
|
posts[postId] = {
|
id: parseInt(postId),
|
};
|
postOrder.push(postId); // Track first occurrence
|
}
|
if (fieldName === 'post_thumbnail') {
|
posts[postId]['post_thumbnail'] = parseInt(form.querySelector(`[name="${key}"]`).closest('.item')?.dataset.id);
|
} else {
|
const processor = this.getFieldProcessor(fieldName);
|
processor(fieldName, value, posts[postId], {}, {}, form);
|
}
|
|
} else {
|
// Shared field (post_title, taxonomies, etc.)
|
const processor = this.getFieldProcessor(key);
|
processor(key, value, data, {}, {}, form);
|
}
|
}
|
|
// Convert to array in DOM order (matches menu_order)
|
data.timeline = postOrder.map(id => posts[id]);
|
|
delete data['form-id'];
|
delete data['sendAll'];
|
delete data['timeline_temp'];
|
delete data['']; // Empty key
|
|
return data;
|
}
|
|
getFieldProcessor(key) {
|
if (key.includes('::')) return this.processGroupField;
|
if (key.includes(':')) return this.processRepeaterField;
|
if (/\[[^\]]+]/.test(key)) 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, fields] of Object.entries(postData)) {
|
data[postId] = fields;
|
}
|
return data;
|
}
|
|
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)
|
key = key.replace('[]','');
|
if (data[key]) {
|
if (!Array.isArray(data[key])) {
|
data[key] = [data[key]];
|
}
|
data[key].push(value);
|
} else {
|
data[key] = value;
|
}
|
}
|
|
/**
|
* Get field value (handles different input types)
|
*/
|
getFieldValue(input) {
|
if (!input) return '';
|
|
if (input.type === 'checkbox') {
|
return input.checked ? input.value || '1' : '';
|
} else if (input.type === 'radio') {
|
const checked = input.form?.querySelector(`[name="${input.name}"]:checked`);
|
return checked ? checked.value : '';
|
} else if (input.type === 'select-multiple') {
|
return Array.from(input.selectedOptions).map(o => o.value);
|
}
|
|
return input.value?.trim() || '';
|
}
|
|
getChangedFields(original, current) {
|
return window.getDifferences?.map(original, current) || {};
|
}
|
|
/*******************************************************
|
Field Summary
|
*******************************************************/
|
/**
|
* Show a comprehensive summary of form submission
|
*/
|
showSummary(formId, clear = 'form') {
|
const formConfig = this.forms.get(formId);
|
if (!formConfig) return;
|
|
const form = formConfig.element || document.querySelector(`[data-form-id="${formId}"]`);
|
const summary = window.getTemplate('formSummary');
|
if (!summary) return;
|
const wrapper = summary.querySelector('.result');
|
|
// Fields to skip in summary
|
const skipFields = ['sendAll', ...this.ignore];
|
|
// Process each field in the form data
|
for (const [key, value] of Object.entries(formConfig.data)) {
|
// Skip ignored fields and empty values
|
if (skipFields.includes(key) || this.isEmptyValue(value)) {
|
continue;
|
}
|
|
// Get field info from form
|
const fieldInfo = this.getFieldInfo(form, key);
|
|
if (!fieldInfo.label) continue; // Skip if no label found
|
|
let field = wrapper.cloneNode(true);
|
let title = field.querySelector('h3');
|
let p = field.querySelector('p');
|
|
title.textContent = fieldInfo.label;
|
|
|
let formatted = this.formatFieldValue(value, fieldInfo.type, form);
|
if (this.isHtmlContent(formatted)) {
|
p.innerHTML = formatted;
|
} else {
|
p.textContent = formatted;
|
}
|
|
summary.append(field);
|
}
|
let uploads = form.querySelectorAll('[data-upload-field]');
|
if (uploads) {
|
uploads.forEach(upload => {
|
let label = upload.querySelector('h2').textContent;
|
|
let imgs = upload.querySelectorAll('.item-grid.preview img');
|
if (imgs) {
|
let field = wrapper.cloneNode(true);
|
let title = field.querySelector('h3');
|
let p = field.querySelector('p');
|
p.remove();
|
|
title.textContent = label;
|
imgs.forEach(img => {
|
img = img.cloneNode(true);
|
field.append(img);
|
});
|
summary.append(field);
|
}
|
});
|
}
|
|
// Remove template
|
wrapper.remove();
|
|
// Insert summary and hide form
|
clear = (clear !== 'form') ? form.closest(clear)??form : form;
|
|
clear.after(summary);
|
window.fade(clear, false);
|
}
|
|
/**
|
* Check if a value is empty (null, undefined, empty string, empty array, empty object)
|
*/
|
isEmptyValue(value) {
|
if (value === null || value === undefined || value === '') {
|
return true;
|
}
|
if (Array.isArray(value) && value.length === 0) {
|
return true;
|
}
|
return typeof value === 'object' && Object.keys(value).length === 0;
|
|
}
|
|
/**
|
* Get field information (label, type, etc.) from the form
|
* Handles special field name patterns ([], ::, :, etc.)
|
*/
|
getFieldInfo(form, fieldName) {
|
// Try to find label by 'for' attribute (exact match)
|
let label = form.querySelector(`label[for="${fieldName}"]`);
|
let input = form.querySelector(`[name=${fieldName}]`);
|
// Try to find the input field - check multiple patterns
|
if (!input) {
|
// Try exact match first
|
input = form.querySelector(`[name="${fieldName}"]`);
|
}
|
|
if (!input) {
|
// Try with [] suffix (for checkboxes, multi-selects)
|
input = form.querySelector(`[name="${fieldName}[]"]`);
|
}
|
|
if (!input) {
|
// Try as fieldset legend (for checkbox/radio groups)
|
const fieldset = form.querySelector(`fieldset[data-field="${fieldName}"]`);
|
if (fieldset) {
|
label = fieldset.querySelector('legend');
|
input = fieldset.querySelector('input, select, textarea');
|
}
|
}
|
|
// Get label from input if not found yet
|
if (!label && input) {
|
// Try closest field wrapper first
|
const field = input.closest('.field, fieldset');
|
if (field) {
|
label = field.querySelector('label, legend, h2');
|
}
|
}
|
|
// Get field wrapper - always use base name (no special characters)
|
let fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`);
|
|
// Determine field type
|
let fieldType = 'text';
|
if (fieldWrapper?.dataset.type) {
|
fieldType = fieldWrapper.dataset.type;
|
} else if (input) {
|
// Infer from input type
|
if (input.type === 'checkbox' && input.name.endsWith('[]')) {
|
fieldType = 'checkbox'; // checkbox group
|
} else if (input.type === 'checkbox') {
|
fieldType = 'true_false'; // single checkbox
|
} else if (input.tagName === 'SELECT' && input.multiple) {
|
fieldType = 'select'; // multi-select
|
} else {
|
fieldType = input.type || 'text';
|
}
|
}
|
|
return {
|
label: label?.textContent.replace('*', '').trim() || null,
|
type: fieldType,
|
wrapper: fieldWrapper,
|
input: input
|
};
|
}
|
|
/**
|
* Check if content should be treated as HTML
|
*/
|
isHtmlContent(content) {
|
return typeof content === 'string' && (
|
content.includes('<br>') ||
|
content.includes('<p>') ||
|
content.includes('<ul>') ||
|
content.includes('<ol>') ||
|
content.includes('<a ') ||
|
content.includes('<strong>') ||
|
content.includes('<em>') ||
|
content.includes('<div')
|
);
|
}
|
|
/**
|
* Format field value based on type
|
*/
|
formatFieldValue(value, type, form) {
|
switch (type) {
|
case 'textarea':
|
case 'wysiwyg':
|
// Handle rich text - check if it's actual HTML content from Quill
|
return this.formatTextareaValue(value, type);
|
|
case 'true_false':
|
return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No';
|
case 'checkbox':
|
// Handle both single checkbox and checkbox groups
|
if (Array.isArray(value)) {
|
return this.formatArrayValue(value);
|
}
|
return (value === '1' || value === 1 || value === true) ? 'Yes' : value;
|
|
case 'select':
|
// Handle both single and multi-select
|
if (Array.isArray(value)) {
|
return this.formatArrayValue(value);
|
}
|
// Get label from select option
|
return this.getSelectLabel(value, form, type);
|
case 'date':
|
case 'datetime':
|
case 'time':
|
return window.formatDate ? window.formatDate(value) : value;
|
|
case 'radio':
|
// Get label from select option or radio label
|
return this.getSelectLabel(value, form, type);
|
|
case 'repeater':
|
return this.formatRepeaterValue(value);
|
|
case 'group':
|
return this.formatGroupValue(value);
|
|
case 'location':
|
return this.formatLocationValue(value);
|
|
case 'upload':
|
return this.formatFileValue(value);
|
|
case 'number':
|
return this.formatNumber(value);
|
|
case 'email':
|
return `<a href="mailto:${value}">${value}</a>`;
|
|
case 'url':
|
return `<a href="${value}" target="_blank" rel="noopener">${value}</a>`;
|
|
case 'phone':
|
return `<a href="tel:${value.replace(/\D/g, '')}">${value}</a>`;
|
|
default:
|
// Handle arrays (multi-select, checkbox group)
|
if (Array.isArray(value)) {
|
return this.formatArrayValue(value);
|
}
|
return value;
|
}
|
}
|
|
/**
|
* Format repeater field value
|
*/
|
formatRepeaterValue(rows) {
|
if (!Array.isArray(rows) || rows.length === 0) {
|
return '<em>No entries</em>';
|
}
|
|
let html = '<div class="repeater-summary">';
|
rows.forEach((row, index) => {
|
html += `<div class="repeater-row">`;
|
html += `<strong>Entry ${index + 1}:</strong><ul>`;
|
for (const [key, value] of Object.entries(row)) {
|
if (!this.isEmptyValue(value)) {
|
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
html += `<li><strong>${label}:</strong> ${value}</li>`;
|
}
|
}
|
html += `</ul></div>`;
|
});
|
html += '</div>';
|
return html;
|
}
|
|
/**
|
* Format group field value
|
*/
|
formatGroupValue(groupData) {
|
if (typeof groupData !== 'object' || Object.keys(groupData).length === 0) {
|
return '<em>No data</em>';
|
}
|
|
let html = '<div class="group-summary"><ul>';
|
for (const [key, value] of Object.entries(groupData)) {
|
if (!this.isEmptyValue(value)) {
|
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
// Handle nested groups
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
html += `<li><strong>${label}:</strong> ${this.formatGroupValue(value)}</li>`;
|
} else {
|
html += `<li><strong>${label}:</strong> ${value}</li>`;
|
}
|
}
|
}
|
html += '</ul></div>';
|
return html;
|
}
|
|
/**
|
* Format location field value
|
*/
|
formatLocationValue(location) {
|
if (typeof location !== 'object') return location;
|
|
const parts = [];
|
const fields = ['address', 'city', 'state', 'zip', 'country'];
|
|
fields.forEach(field => {
|
if (location[field]) {
|
parts.push(location[field]);
|
}
|
});
|
|
return parts.join(', ');
|
}
|
|
/**
|
* Format file/image value
|
*/
|
formatFileValue(value) {
|
if (typeof value === 'string') {
|
// Single file - could be URL or filename
|
if (value.startsWith('http')) {
|
return `<a href="${value}" target="_blank">View file</a>`;
|
}
|
return value;
|
}
|
|
if (Array.isArray(value)) {
|
return value.map(file => {
|
if (typeof file === 'string') {
|
return `<a href="${file}" target="_blank">View file</a>`;
|
}
|
return file.name || 'File';
|
}).join(', ');
|
}
|
|
return 'File uploaded';
|
}
|
|
/**
|
* Format number with proper locale formatting
|
*/
|
formatNumber(value) {
|
const num = parseFloat(value);
|
if (isNaN(num)) return value;
|
|
// Check if it's likely currency (has 2 decimal places)
|
if (value.toString().includes('.') && value.toString().split('.')[1].length === 2) {
|
return new Intl.NumberFormat('en-CA', {
|
style: 'currency',
|
currency: 'USD'
|
}).format(num);
|
}
|
|
return new Intl.NumberFormat('en-CA').format(num);
|
}
|
|
/**
|
* Format array values (checkboxes, multi-select)
|
*/
|
/**
|
* Format array values (checkboxes, multi-select)
|
*/
|
formatArrayValue(arr, form = null, fieldInfo = null) {
|
if (arr.length === 0) return '<em>None selected</em>';
|
|
// If we have field info, try to get proper labels
|
if (form && fieldInfo && fieldInfo.input) {
|
const labeled = arr.map(val => {
|
return this.getSelectLabel(val, form, fieldInfo.type);
|
});
|
return '<ul><li>' + labeled.join('</li><li>') + '</li></ul>';
|
}
|
|
// Fallback to raw values
|
return '<ul><li>' + arr.join('</li><li>') + '</li></ul>';
|
}
|
|
/**
|
* Get label for select/radio option
|
*/
|
/**
|
* Get label for select/radio/checkbox option
|
*/
|
getSelectLabel(value, form, type) {
|
if (type === 'select') {
|
const option = form.querySelector(`option[value="${value}"]`);
|
return option?.textContent || value;
|
}
|
|
if (type === 'radio') {
|
const radio = form.querySelector(`input[type="radio"][value="${value}"]`);
|
const label = radio?.nextElementSibling;
|
return label?.textContent || value;
|
}
|
|
if (type === 'checkbox') {
|
// Try to find the checkbox with this value
|
const checkbox = form.querySelector(`input[type="checkbox"][value="${value}"]`);
|
if (checkbox) {
|
// Look for associated label
|
const label = form.querySelector(`label[for="${checkbox.id}"]`);
|
if (label) {
|
return label.textContent.trim();
|
}
|
// Try next sibling
|
const nextLabel = checkbox.nextElementSibling;
|
if (nextLabel?.tagName === 'LABEL') {
|
return nextLabel.textContent.trim();
|
}
|
}
|
}
|
|
return value;
|
}
|
|
/**
|
* Format textarea value - handles both rich text and plain text
|
*/
|
formatTextareaValue(value, type) {
|
if (!value) return '<em>Empty</em>';
|
|
// If it's explicitly a wysiwyg type or contains HTML tags, use as-is
|
if (type === 'wysiwyg' || this.containsHtml(value)) {
|
// Quill content already has proper HTML structure
|
return value;
|
}
|
|
// Plain textarea - preserve formatting
|
return this.formatPlainText(value);
|
}
|
|
/**
|
* Check if string contains HTML content (more reliable than just checking for '<')
|
*/
|
containsHtml(str) {
|
// Check for common HTML tags that Quill uses
|
const htmlPattern = /<(p|strong|em|u|s|ol|ul|li|blockquote|h[1-6]|a|br|span)\b[^>]*>/i;
|
return htmlPattern.test(str);
|
}
|
|
/**
|
* Format plain text content - preserves whitespace and converts newlines
|
*/
|
formatPlainText(text) {
|
if (!text) return '';
|
|
// First, escape any HTML entities that might be in the text
|
text = text
|
.replace(/&/g, '&')
|
.replace(/</g, '<')
|
.replace(/>/g, '>');
|
|
// Convert double newlines to paragraphs for better readability
|
const paragraphs = text.split(/\n\n+/);
|
|
if (paragraphs.length > 1) {
|
// Multiple paragraphs
|
return paragraphs
|
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
.join('');
|
}
|
|
// Single paragraph - just convert newlines to breaks
|
return text.replace(/\n/g, '<br>');
|
}
|
|
/**
|
* Event system
|
*/
|
subscribe(callback) {
|
this.subscribers.add(callback);
|
return () => this.subscribers.delete(callback);
|
}
|
|
notify(event, data) {
|
this.subscribers.forEach(cb => cb(event, data));
|
}
|
|
/**
|
* Cleanup when form is closed/destroyed
|
*/
|
cleanupForm(formId) {
|
const formConfig = this.forms.get(formId);
|
if (!formConfig) return;
|
|
// Check for unsaved changes
|
if (this.hasUnsavedChanges(formId)) {
|
this.autosave(formConfig);
|
}
|
|
// Clean up special fields
|
this.cleanupSpecialFields();
|
|
// Remove form config
|
this.forms.delete(formId);
|
}
|
|
/**
|
* Cleanup
|
*/
|
destroy() {
|
// Remove global handlers
|
if (this.globalHandlersAdded) {
|
document.removeEventListener('change', this.changeHandler);
|
document.removeEventListener('blur', this.blurHandler, true);
|
document.removeEventListener('input', this.inputHandler, true);
|
}
|
this.forms.forEach((formConfig) => {
|
let element = formConfig.element;
|
if (element) {
|
element.removeEventListener('submit', this.submitHandler);
|
}
|
});
|
|
// Clear maps
|
this.specialFields.clear();
|
this.forms.clear();
|
this.activeRepeaters.clear();
|
|
if (this.forms) {
|
this.forms.clear();
|
}
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', async function () {
|
window.auth.subscribe(event => {
|
if (event === 'auth-loaded') {
|
window.jvbForm = FormController;
|
}
|
});
|
|
});
|