/**
|
* 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(store = null) {
|
this.store = store; // Optional - for CRUD operations
|
if (!store) {
|
this.store = new window.jvbStore({name:'forms', TTL: 604800});
|
}
|
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();
|
|
// 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();
|
|
// Set up global form handlers for standalone forms
|
this.initListeners();
|
}
|
|
/**
|
* Check for pending operations from previous session
|
*/
|
async checkPendingOperations() {
|
if (!this.store) return;
|
try {
|
let pending = this.store.getAllForms();
|
|
} catch (error) {
|
console.error('Failed to load pending forms:', error);
|
}
|
}
|
|
/**
|
* 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.clearForm(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('submit', this.submitHandler);
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
document.addEventListener('focus', this.focusHandler, true);
|
document.addEventListener('blur', this.blurHandler, true);
|
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;
|
|
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.getForm(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')) {
|
new window.jvbTabs(form);
|
}
|
|
// Scan for existing selector fields
|
if (window.jvbSelector) {
|
window.jvbSelector.scanExistingFields();
|
}
|
}
|
|
/**
|
* 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() {
|
window.jvbUploads.scanFields();
|
}
|
|
/* ========== Event Handlers ========== */
|
|
handleSubmit(event) {
|
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);
|
|
event.preventDefault();
|
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'));
|
}
|
}
|
|
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(event) {
|
const target = event.target;
|
const form = target.form || target.closest('form');
|
|
if (!form) return;
|
|
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
|
});
|
}
|
}
|
|
/* ========== Auto-save functionality ========== */
|
/**
|
* Get appropriate delay based on field type and context
|
*/
|
getDelayForField(field) {
|
console.log('Get Delay for Field', 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.handleInput, {passive: true});
|
const saveKey = `autosave_${formConfig.id}`;
|
|
this.debouncer.schedule(
|
saveKey,
|
() => this.autosave(formConfig),
|
delay
|
);
|
}
|
|
//Extend delay if user is currently typing
|
handleInput(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.cacheFormData(formConfig, formData);
|
|
// 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
|
});
|
}
|
|
cacheFormData(formConfig, formData) {
|
try {
|
this.store.storeForm(formConfig.id, {
|
formId: formConfig.id,
|
formData: formData,
|
timestamp: Date.now(),
|
status: 'pending',
|
operationId: null
|
});
|
} catch (error) {
|
console.error('Failed to cache form data:', error);
|
}
|
}
|
|
/**
|
* 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(form, status) {
|
// Remove existing status
|
const existingStatus = form.querySelector('.form-status');
|
if (existingStatus) {
|
existingStatus.remove();
|
}
|
|
// Add new status
|
const statusElement = document.createElement('div');
|
statusElement.className = `form-status status-${status}`;
|
|
const messages = {
|
'saving': 'Saving changes...',
|
'saved': 'Changes saved',
|
'error': 'Failed to save changes',
|
'offline': 'Changes will be saved when online'
|
};
|
|
statusElement.textContent = messages[status] || status;
|
form.insertBefore(statusElement, form.firstChild);
|
|
// Auto-hide success messages
|
if (status === 'saved') {
|
setTimeout(() => statusElement.remove(), 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 (key.includes('[')) return this.processLocationField;
|
return this.processRegularField;
|
}
|
|
mergeRepeaterData(data, repeaterData) {
|
Object.keys(repeaterData).forEach(fieldName => {
|
// Clean up empty rows and convert to array format
|
const cleanedRows = {};
|
Object.keys(repeaterData[fieldName]).forEach(index => {
|
const rowData = repeaterData[fieldName][index];
|
if (Object.keys(rowData).length > 0) {
|
cleanedRows[index] = rowData;
|
}
|
});
|
|
// Convert to sequential array
|
data[fieldName] = Object.values(cleanedRows);
|
});
|
return data;
|
}
|
|
mergePostData(data, postData) {
|
for (let [postId, postData] in Object.entries(postData)) {
|
data[postId] = postData;
|
}
|
return data;
|
}
|
|
processTableField(key, value, data, repeaterData, postData, form) {
|
/***
|
* Table forms are a huge form containing multiple posts and their data
|
* Field names are prepended with `${postID}|`
|
* Goal:
|
* 1) Separate out the post id from the field name
|
* 2) store the original data in a temporary 'original' variable
|
* 3) Process the field as normal
|
* 4) return the original data, as PostID: {$field data}
|
* Final format:
|
* {
|
* id1: {
|
* field1: "A title",
|
* field3: 32
|
* },
|
* id2: {
|
* field1: "Another title",
|
* field2: "122,21,32"
|
* }
|
* }
|
**/
|
let [post, fieldKey] = key.split('|');
|
if (!post in postData) {
|
postData[post] = {};
|
}
|
|
const processor = this.getFieldProcessor(fieldKey);
|
processor(fieldKey, value, postData, repeaterData, postData, form);
|
|
}
|
processRepeaterField(key, value, data, repeaterData, postData, form) {
|
let [fieldName, index, subField] = key.split(':');
|
|
const isArray = subField.endsWith('[]');
|
subField = subField.replace('[]', '');
|
|
//Ensure this repeater and row is in repeaterData
|
if (!repeaterData[fieldName]) {
|
repeaterData[fieldName] = {};
|
}
|
if (!repeaterData[fieldName][index]) {
|
repeaterData[fieldName][index] = {};
|
}
|
|
if (isArray || repeaterData[fieldName][index][subField]) {
|
// Initialize as array if not already
|
if (!repeaterData[fieldName][index][subField]) {
|
repeaterData[fieldName][index][subField] = [];
|
} else if (!Array.isArray(repeaterData[fieldName][index][subField])) {
|
repeaterData[fieldName][index][subField] = [repeaterData[fieldName][index][subField]];
|
}
|
repeaterData[fieldName][index][subField].push(value);
|
} else {
|
// Single value field
|
repeaterData[fieldName][index][subField] = value;
|
}
|
}
|
processGroupField(key, value, data, repeaterData, postData, form) {
|
const keys = key.split('::');
|
const rootGroup = keys[0];
|
|
// Initialize root group if it doesn't exist
|
if (!data[rootGroup]) {
|
data[rootGroup] = {};
|
}
|
|
// Build nested structure step by step
|
let current = data[rootGroup];
|
for (let i = 1; i < keys.length - 1; i++) {
|
const groupKey = keys[i];
|
if (!current[groupKey]) {
|
current[groupKey] = {};
|
}
|
current = current[groupKey];
|
}
|
|
// Set the final field value
|
const fieldKey = keys[keys.length - 1];
|
|
// Handle array values (checkboxes, multi-selects)
|
if (current[fieldKey] !== undefined) {
|
if (!Array.isArray(current[fieldKey])) {
|
current[fieldKey] = [current[fieldKey]];
|
}
|
current[fieldKey].push(value);
|
} else {
|
current[fieldKey] = value;
|
}
|
}
|
processLocationField(key, value, data, repeaterData, postData, form) {
|
let [fieldKey, v ] = key.split('[');
|
v = v.replace(']','');
|
if (!Object.hasOwn(data, fieldKey)) {
|
data[fieldKey] = {};
|
|
if (!Object.hasOwn(data, 'sendAll')) {
|
data['sendAll'] = [fieldKey];
|
} else if (!data['sendAll'].includes(fieldKey)) {
|
data['sendAll'].push(fieldKey);
|
}
|
}
|
data[fieldKey][v] = value;
|
}
|
|
processRegularField(key, value, data, repeaterData, postData, form) {
|
//handle array values (like checkboxes/selects)
|
if (data[key]) {
|
if (!Array.isArray(data[key])) {
|
data[key] = [data[key]];
|
}
|
data[key].push(value);
|
} else {
|
data[key] = value;
|
}
|
}
|
|
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) || {};
|
}
|
|
/**
|
* 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;
|
console.log('Cleaning up form', formConfig);
|
|
// 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('submit', this.submitHandler);
|
document.removeEventListener('change', this.changeHandler);
|
document.removeEventListener('focus', this.focusHandler, true);
|
document.removeEventListener('blur', this.blurHandler, true);
|
}
|
|
// Clear maps
|
this.specialFields.clear();
|
this.forms.clear();
|
this.activeRepeaters.clear();
|
|
if (this.forms) {
|
this.forms.clear();
|
}
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', () => {
|
window.jvbForm = FormController;
|
});
|