/**
|
* 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() {
|
this.store = new window.jvbStore({
|
name:'forms',
|
storeName: 'forms',
|
keyPath: 'formId',
|
indexes: [
|
{ name: 'status', keyPath: 'status' },
|
{ name: 'operationId', keyPath: 'operationId' },
|
{ name: 'timestamp', keyPath: 'timestamp' },
|
{ name: 'formType', keyPath: 'type' }
|
],
|
TTL: 604800000, //7 days
|
});
|
|
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
|
};
|
|
// 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.focusHandler = this.handleFocus.bind(this);
|
this.blurHandler = this.handleBlur.bind(this);
|
|
this.init();
|
}
|
|
async init() {
|
// Check for pending operations on page load
|
await this.checkPendingOperations();
|
|
this.store.subscribe(this.handleStoreEvent.bind(this));
|
|
// Set up global form handlers for standalone forms
|
this.initListeners();
|
}
|
|
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;
|
}
|
}
|
|
async checkPendingForms() {
|
let items = await this.store.query('status', 'draft');
|
items.forEach(item => {
|
let form = this.forms.get(item.formId);
|
if (form && form.element) {
|
form.element.querySelector('.restore-form').hidden = false;
|
new this.populateForm(form.element, item.data);
|
}
|
});
|
|
}
|
/**
|
* Check for pending operations from previous session
|
*/
|
async checkPendingOperations() {
|
const pendingForms = await this.store.query('status', 'pending');
|
|
if (pendingForms.length === 0) return;
|
|
// Group by form type or page
|
const grouped = this.groupPendingForms(pendingForms);
|
|
// Show consolidated notification
|
this.showPendingNotification(grouped);
|
}
|
|
/**
|
* Show notification for pending changes
|
*/
|
showPendingNotification(pendingData) {
|
const formElement = document.querySelector(`[data-form-id="${pendingData.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="${pendingData.formId}">Restore</button>
|
<button class="discard-changes" data-form-id="${pendingData.formId}">Discard</button>
|
`;
|
|
formElement.insertBefore(notification, formElement.firstChild);
|
|
// Add handlers
|
notification.querySelector('.restore-changes').addEventListener('click', () => {
|
this.restorePendingForm(pendingData);
|
notification.remove();
|
});
|
|
notification.querySelector('.discard-changes').addEventListener('click', () => {
|
this.discardPendingForm(pendingData.formId);
|
notification.remove();
|
});
|
}
|
|
/**
|
* Restore pending form data
|
*/
|
restorePendingForm(pendingData) {
|
const form = document.querySelector(`[data-form-id="${pendingData.formId}"]`);
|
if (!form) return;
|
|
// Populate form with cached data
|
new this.populateForm(form, pendingData.formData);
|
|
// Mark as restored
|
pendingData.status = 'restored';
|
this.pendingForms.set(pendingData.formId, pendingData);
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Previous changes restored');
|
}
|
}
|
|
/**
|
* Discard pending form data
|
*/
|
async discardPendingForm(formId) {
|
this.store.delete(formId);
|
|
if (window.jvbA11y) {
|
window.jvbA11y.announce('Previous changes discarded');
|
}
|
}
|
|
/**
|
* 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('focus', this.focusHandler, true);
|
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 = {}) {
|
const formId = formElement.dataset.formId || `form_${Date.now()}`;
|
formElement.dataset.formId = formId;
|
|
formElement.addEventListener('submit', this.submitHandler);
|
|
const formConfig = {
|
element: formElement,
|
id: formId,
|
options: {
|
autoSave: true,
|
saveDelay: this.autoSaveDefaults.delay,
|
endpoint: formElement.dataset.save,
|
cache: true,
|
...options
|
},
|
dependencies: new Map(),
|
data: this.collectFormData(formElement),
|
isDirty: false
|
};
|
|
// Initialize special fields
|
this.initializeFormFields(formElement, formConfig);
|
|
// Store form config
|
this.forms.set(formId, formConfig);
|
|
// Check for pending data
|
if (this.store && formConfig.options.cache) {
|
const cached = this.store.get(formId);
|
if (cached && cached.formData) {
|
this.showPendingNotification(cached);
|
}
|
}
|
|
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);
|
|
// Initialize conditional fields
|
if (formConfig) {
|
this.initConditionalFields(form, formConfig);
|
}
|
|
// Initialize character limits
|
this.initCharacterLimits(form);
|
|
// Initialize image upload fields
|
this.initImageUploadFields(form);
|
|
// 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();
|
}
|
}
|
|
/**
|
* 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);
|
|
// Schedule save if auto-save enabled
|
if (formConfig && formConfig.options.autoSave) {
|
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 && formConfig.options.autoSave) {
|
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 && formConfig.options.autoSave) {
|
this.scheduleSave(formConfig, {
|
type: 'repeater',
|
action: 'reorder',
|
fieldName: fieldName,
|
delay: this.repeaterDelays.reorder
|
});
|
}
|
}
|
|
/**
|
* 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) {
|
window.jvbUploads.scanFields(form);
|
}
|
|
/* ========== Event Handlers ========== */
|
|
handleSubmit(event) {
|
//TODO: submit data, if successful, delete from store
|
if (this.subscribers.size > 0 ){
|
const form = event.target;
|
if (!form.dataset.formId) return;
|
event.preventDefault();
|
|
const formConfig = this.forms.get(form.dataset.formId);
|
if (!formConfig) return;
|
|
const formData = this.collectFormData(form);
|
this.notify('form-submit', {
|
formId: formConfig.id,
|
data: formData,
|
config: formConfig
|
});
|
}
|
}
|
|
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 action = window.targetCheck(e, '[data-action]');
|
action = action.dataset.action;
|
switch (action) {
|
case 'clear-form':
|
let form = e.target.closest('form');
|
this.store.delete(form.dataset.formId);
|
form?.reset();
|
e.target.closest('.restore-form').hidden = true;
|
break;
|
case 'dismiss-restore':
|
e.target.closest('.restore-form').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 (this.subscribers.size > 0) {
|
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;
|
|
// 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
|
if (formConfig.options.autoSave && !form.dataset.noautosave) {
|
const delay = this.getDelayForField(target);
|
this.scheduleSave(formConfig, delay);
|
}
|
}
|
}
|
|
handleFocus(event) {
|
const target = event.target;
|
if (target.matches('input, textarea, select')) {
|
// Track focus for better UX
|
this.currentFocus = target;
|
}
|
}
|
|
handleBlur(e) {
|
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 && formConfig.options.autoSave && !form.dataset.noautosave) {
|
// Shorter delay on blur
|
this.scheduleSave(formConfig, {
|
type: 'blur',
|
fieldName: target.name,
|
delay: 1500
|
});
|
}
|
}
|
}
|
|
handleInput(e) {
|
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}`,
|
(input, fieldWrapper) => 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 http:// or 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);
|
return true;
|
}
|
|
/**
|
* 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() || '';
|
}
|
|
/**
|
* Show success state (green checkmark)
|
*/
|
showSuccess(fieldWrapper) {
|
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) {
|
message.hidden = true;
|
message.textContent = '';
|
}
|
}
|
|
/**
|
* 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 = '';
|
}
|
}
|
|
/**
|
* Validate all fields in a container (useful for step validation)
|
*/
|
validateAllFields(container) {
|
if (!container) return true;
|
|
const fields = container.querySelectorAll('.field:not([hidden])');
|
let allValid = true;
|
|
fields.forEach(fieldWrapper => {
|
// Skip complex parent wrappers (repeater, group) - validate their children
|
if (this.isComplexFieldWrapper(fieldWrapper)) {
|
return;
|
}
|
|
const input = fieldWrapper.querySelector('input:not([type="hidden"]), textarea, select');
|
if (input && !input.closest('[hidden]')) {
|
// Mark as touched so validation will run
|
const fieldName = fieldWrapper.dataset.field;
|
if (fieldName) {
|
this.touchedFields.add(fieldName);
|
}
|
|
const isValid = this.validateField(input, fieldWrapper);
|
if (!isValid) {
|
allValid = false;
|
|
// Scroll to first error
|
if (allValid === false) {
|
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
input.focus();
|
}
|
}
|
}
|
});
|
|
return allValid;
|
}
|
|
/**
|
* Check if field wrapper is a complex type (repeater, group, etc.)
|
*/
|
isComplexFieldWrapper(fieldWrapper) {
|
return fieldWrapper.classList.contains('repeater') ||
|
fieldWrapper.classList.contains('group') ||
|
fieldWrapper.classList.contains('upload');
|
}
|
|
/**
|
* Special validation for repeater fields
|
*/
|
attachRepeaterValidation(form) {
|
// When a repeater row is added, attach validation to its fields
|
form.addEventListener('click', (e) => {
|
if (e.target.closest('.add-repeater-row')) {
|
// Wait for the DOM to update
|
setTimeout(() => {
|
const repeaterRows = form.querySelectorAll('.repeater-row');
|
repeaterRows.forEach(row => {
|
const inputs = row.querySelectorAll('input, textarea, select');
|
inputs.forEach(input => {
|
const fieldWrapper = this.findFieldWrapper(input);
|
if (fieldWrapper) {
|
// Validation listeners are already attached via event delegation
|
// Just clear any existing validation state for new rows
|
this.clearValidation(fieldWrapper);
|
}
|
});
|
});
|
}, 100);
|
}
|
});
|
}
|
|
/**
|
* Special validation for group fields
|
*/
|
attachGroupValidation(form) {
|
// Group fields might have conditional fields
|
// Validate when conditions change
|
form.addEventListener('change', (e) => {
|
const changedInput = e.target.closest('input, select');
|
if (!changedInput) return;
|
|
// Check if this change affects conditional fields
|
const fieldName = changedInput.name;
|
if (!fieldName) return;
|
|
// Find any conditional fields that depend on this field
|
const conditionalFields = form.querySelectorAll(`[data-show-if*="${fieldName}"]`);
|
conditionalFields.forEach(conditionalField => {
|
// Clear validation for hidden fields
|
if (conditionalField.hidden) {
|
this.clearValidation(conditionalField);
|
}
|
});
|
});
|
}
|
|
/**
|
* Reset validation state for a form
|
*/
|
resetForm(form) {
|
if (!form) return;
|
|
// Clear all touched fields
|
this.touchedFields.clear();
|
|
// Clear all validation states
|
const fields = form.querySelectorAll('.field');
|
fields.forEach(fieldWrapper => {
|
this.clearValidation(fieldWrapper);
|
});
|
}
|
|
/**
|
* Get validation errors for a form
|
*/
|
getFormErrors(form) {
|
const errors = {};
|
const fields = form.querySelectorAll('.field.has-error');
|
|
fields.forEach(fieldWrapper => {
|
const fieldName = fieldWrapper.dataset.field;
|
const message = fieldWrapper.querySelector('.validation-message');
|
if (fieldName && message) {
|
errors[fieldName] = message.textContent;
|
}
|
});
|
|
return errors;
|
}
|
|
/**
|
* Add custom validator
|
*/
|
addValidator(name, validator) {
|
this.validators[name] = validator;
|
}
|
/* ========== 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) {
|
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');
|
await this.store.save({
|
formId: formConfig.id,
|
data: formData,
|
status: 'draft',
|
timestamp: Date.now()
|
}).then(()=> {
|
this.showFormStatus(formConfig.id, 'autosaved');
|
});
|
|
// 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)) {
|
//We want all data for complex fields, like group, repeater, or location
|
if (typeof value === 'object') {
|
changes[key] = value;
|
}
|
}
|
// Notify instead of callback
|
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.lastSnapshot, currentData);
|
|
return Object.keys(changes).length > 0;
|
}
|
|
showFormStatus(formID, status) {
|
// Remove existing status
|
let form = this.forms.get(formID);
|
|
console.log('Setting status: ', status);
|
|
// Add new status
|
const statusWrap = form.element.querySelector('.fstatus');
|
statusWrap.hidden = false;
|
const statusElement = statusWrap.querySelector('.message');
|
statusElement.textContent = '';
|
statusWrap.querySelector('.icon')?.remove();
|
|
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',
|
'error': 'Failed to save changes. Refresh and try again?',
|
'offline': 'Changes will be saved when online'
|
};
|
const icons = {
|
'autosaved': 'check',
|
'submitted': 'check',
|
'error': 'close',
|
'offline': 'cloud-slash',
|
'pending': 'exclamation-mark'
|
}
|
|
let icon = window.getIcon(icons[status]);
|
if (icon) {
|
statusWrap.prepend(icon);
|
}
|
console.log(status, messages[status]);
|
console.log(status, icons[status]);
|
statusElement.textContent = messages[status] || status;
|
statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
|
|
// 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) {
|
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 (/\[[^\]]+\]/.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, 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)
|
key = key.replace('[]','');
|
if (data[key]) {
|
if (!Array.isArray(data[key])) {
|
data[key] = [data[key]];
|
}
|
data[key].push(value);
|
} else {
|
data[key] = value;
|
}
|
}
|
|
getFieldValue(field) {
|
if (!field) return '';
|
|
if (field.type === 'checkbox') {
|
return field.checked ? field.value || '1' : '';
|
} else if (field.type === 'radio') {
|
const checked = field.form.querySelector(`[name="${field.name}"]:checked`);
|
return checked ? checked.value : '';
|
} else if (field.type === 'select-multiple') {
|
return Array.from(field.selectedOptions).map(o => o.value);
|
} else {
|
return field.value;
|
}
|
}
|
|
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');
|
|
const [
|
title,
|
resultWrapper,
|
resultTemplate
|
] = [
|
summary.querySelector('h2'),
|
summary.querySelector('.summary'),
|
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
|
|
// Create result element
|
const resultEl = this.createResultElement(
|
resultTemplate,
|
fieldInfo,
|
value,
|
form
|
);
|
|
if (resultEl) {
|
resultWrapper.appendChild(resultEl);
|
}
|
}
|
|
// Remove template
|
resultTemplate.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;
|
}
|
if (typeof value === 'object' && Object.keys(value).length === 0) {
|
return true;
|
}
|
return false;
|
}
|
|
/**
|
* 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 = null;
|
let fieldWrapper = null;
|
|
// 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');
|
}
|
}
|
|
// Get field wrapper - always use base name (no special characters)
|
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
|
};
|
}
|
|
/**
|
* Create a result element for a field
|
*/
|
createResultElement(template, fieldInfo, value, form) {
|
const resultEl = template.cloneNode(true);
|
const titleEl = resultEl.querySelector('h4');
|
const valueEl = resultEl.querySelector('p');
|
|
// Set label
|
titleEl.textContent = fieldInfo.label;
|
|
// Format value based on field type
|
const formattedValue = this.formatFieldValue(value, fieldInfo.type, form);
|
|
// Determine how to set the value
|
if (this.isHtmlContent(formattedValue)) {
|
// HTML content - use innerHTML
|
valueEl.innerHTML = formattedValue;
|
} else {
|
// Plain text - use textContent for safety
|
valueEl.textContent = formattedValue;
|
}
|
|
return resultEl;
|
}
|
|
/**
|
* 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' : 'No';
|
|
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 'file':
|
case 'image':
|
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>');
|
}
|
|
/**
|
* Convert newlines to <br> tags (kept for backwards compatibility)
|
*/
|
nl2br(text) {
|
return this.formatPlainText(text);
|
}
|
|
/**
|
* 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('focus', this.focusHandler, true);
|
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', () => {
|
window.jvbForm = FormController;
|
});
|