From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter
---
assets/js/concise/FormController.js | 1241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 files changed, 1,175 insertions(+), 66 deletions(-)
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index eea0597..9a3b075 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -3,11 +3,20 @@
* 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});
- }
+ 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 = [];
@@ -19,6 +28,10 @@
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
@@ -52,21 +65,49 @@
// 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() {
- if (!this.store) return;
- try {
- let pending = this.store.getAllForms();
+ const pendingForms = await this.store.query('status', 'pending');
- } catch (error) {
- console.error('Failed to load pending forms:', error);
- }
+ if (pendingForms.length === 0) return;
+
+ // Group by form type or page
+ const grouped = this.groupPendingForms(pendingForms);
+
+ // Show consolidated notification
+ this.showPendingNotification(grouped);
}
/**
@@ -121,7 +162,7 @@
* Discard pending form data
*/
async discardPendingForm(formId) {
- this.store.clearForm(formId);
+ this.store.delete(formId);
if (window.jvbA11y) {
window.jvbA11y.announce('Previous changes discarded');
@@ -134,11 +175,11 @@
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);
+ document.addEventListener('input', this.inputHandler);
this.globalHandlersAdded = true;
}
}
@@ -150,13 +191,15 @@
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,
+ autoSave: 'autosave' in formElement.dataset,
saveDelay: this.autoSaveDefaults.delay,
- endpoint: formElement.dataset.save,
+ endpoint: formElement.dataset.save??'',
cache: true,
...options
},
@@ -173,7 +216,7 @@
// Check for pending data
if (this.store && formConfig.options.cache) {
- const cached = this.store.getForm(formId);
+ const cached = this.store.get(formId);
if (cached && cached.formData) {
this.showPendingNotification(cached);
}
@@ -205,16 +248,132 @@
// Initialize tabs if present
if (window.jvbTabs && form.querySelector('nav.tabs')) {
- new window.jvbTabs(form);
+ 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();
+ 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) {
@@ -485,25 +644,23 @@
/**
* Initialize image upload fields
*/
- initImageUploadFields() {
- window.jvbUploads.scanFields();
+ 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);
-
- event.preventDefault();
this.notify('form-submit', {
formId: formConfig.id,
data: formData,
@@ -516,9 +673,24 @@
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;
@@ -574,6 +746,9 @@
}
handleChange(event) {
+ if (event.target.closest('[data-ignore]')) {
+ return;
+ }
if (this.subscribers.size > 0) {
const target = event.target;
const form = target.form || target.closest('form');
@@ -607,29 +782,463 @@
}
}
- handleBlur(event) {
- const target = event.target;
+ handleBlur(e) {
+ if (e.target.closest('[data-ignore]')) {
+ return;
+ }
+ const target = e.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
- });
+
+ 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) {
+ if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) {
+ 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}`,
+ (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) {
- 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;
@@ -644,7 +1253,7 @@
return this.autoSaveDefaults.delay;
}
scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
- document.addEventListener('input', this.handleInput, {passive: true});
+ document.addEventListener('input', this.saveCheck, {passive: true});
const saveKey = `autosave_${formConfig.id}`;
this.debouncer.schedule(
@@ -655,17 +1264,27 @@
}
//Extend delay if user is currently typing
- handleInput(e) {
+ 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.cacheFormData(formConfig, formData);
+
+ 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);
@@ -691,20 +1310,6 @@
});
}
- 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
*/
@@ -722,30 +1327,48 @@
return Object.keys(changes).length > 0;
}
- showFormStatus(form, status) {
+ showFormStatus(formID, status) {
// Remove existing status
- const existingStatus = form.querySelector('.form-status');
- if (existingStatus) {
- existingStatus.remove();
- }
+ let form = this.forms.get(formID);
+
+ console.log('Setting status: ', status);
// Add new status
- const statusElement = document.createElement('div');
- statusElement.className = `form-status status-${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...',
- 'saved': 'Changes saved',
- 'error': 'Failed to save 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;
- form.insertBefore(statusElement, form.firstChild);
+ statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
// Auto-hide success messages
- if (status === 'saved') {
- setTimeout(() => statusElement.remove(), 3000);
+ if (status === 'submitted') {
+ setTimeout(() => statusWrap.hidden = true, 3000);
}
}
@@ -790,7 +1413,7 @@
if (key.includes('|')) return this.processTableField;
if (key.includes('::')) return this.processGroupField;
if (key.includes(':')) return this.processRepeaterField;
- if (key.includes('[')) return this.processLocationField;
+ if (/\[[^\]]+\]/.test(key)) return this.processLocationField;
return this.processRegularField;
}
@@ -924,6 +1547,7 @@
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]];
@@ -953,6 +1577,485 @@
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
*/
@@ -971,7 +2074,6 @@
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)) {
@@ -991,11 +2093,17 @@
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);
+ 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();
@@ -1010,4 +2118,5 @@
document.addEventListener('DOMContentLoaded', () => {
window.jvbForm = FormController;
+ console.log('FormController in window');
});
--
Gitblit v1.10.0