From 2127b1bdd73ecd2423e443992da4b442f5a3c1a3 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Wed, 04 Feb 2026 21:19:25 +0000
Subject: [PATCH] =Major overhaul of MetaManager.php -> Meta.php and RestRouteManager.php -> Rest.php. Seems to work for JakeVan
---
assets/js/concise/FormController.js | 3702 ++++++++++++++++++++++++++++-------------------------------
1 files changed, 1,772 insertions(+), 1,930 deletions(-)
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 9a3b075..07185b6 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -1,868 +1,205 @@
-/**
- * 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.a11y = window.jvbA11y;
+ this.error = window.jvbError;
+ this.queue = window.jvbQueue;
+ this.populate = window.jvbPopulate;
- this.debouncer = window.debouncer;
-
- this.ignore = [];
-
- this.populateForm = window.jvbPopulate;
-
- this.subscribers = new Set();
+ this.changes = new Map();
this.forms = new Map();
- this.specialFields = new Map();
+ this.inputs = new Map();
+ this.repeaters = new Map();
+ this.tagLists = new Map();
+ this.charLimits = new Map();
+ this.quantityFields = new Map();
+ this.quillInstances = new Map(); // formId -> Set of quill instances
this.dependencies = new Map();
- // Validation (YOU ARE GREAT!)
- this.validators = this.initValidators();
- this.touchedFields = new Set();
+ this.subscribers = 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.isRestoring = false;
+ this.hasListeners = false;
+ this.summaryTemplate = false;
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
+ init() {
+ this.templates = window.jvbTemplates;
+ this.defineSummaryTemplate();
+ this.initElements();
this.initListeners();
+ this.initStore();
+ this.initValidators();
}
-
- 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: 'autosave' in formElement.dataset,
- saveDelay: this.autoSaveDefaults.delay,
- endpoint: formElement.dataset.save??'',
- cache: true,
- ...options
+ initElements() {
+ this.inputSelectors = 'input, textarea, select';
+ this.selectors = {
+ tabs: {
+ nav: 'nav.tabs',
+ sections: '.tab.content', //querySelectorAll
+ progress: {
+ progress: '.progress',
+ fill: '.progress .fill',
+ details: '.progress .details',
+ icon: '.progress .icon'
+ },
+ buttons: 'nav.tabs button',
},
- 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(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');
+ dependsOn: '[data-depends-on]',
+ forms: {
+ status: {
+ status: '.fstatus',
+ message: '.fstatus .message',
+ icon: '.fstatus .icon',
+ actions: '.fstatus .actions'
}
- });
- };
-
- // 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);
+ },
+ inputs: this.inputSelectors, //querySelectorAll
+ fields: {
+ field: '.field', //querySelectorAll
+ label: 'label',
+ success: '.success',
+ error: '.success',
+ message: '.validation-message',
+ },
+ repeater: {
+ repeater: '.repeater', //querySelectorAll
+ header: '.repeater-row-header',
+ remove: '.remove-row',
+ add: '.add-repeater-row',
+ template: 'template',
+ items: '.repeater-items',
+ inputs: this.inputSelectors //querySelectorAll
+ },
+ tagList: {
+ tagList: '.field.tag-list', //querySelectorAll
+ input: '.row',
+ add: '.add-tag',
+ remove: '.remove-tag',
+ label: '.tag-label',
+ items: '.tag-items',
+ inputs: this.inputSelectors, //querySelectorAll
+ value: 'input[type="hidden"]' //querySelectorAll
+ },
+ tag: {
+ label: '.tag-label'
+ },
+ number: {
+ number: '.field div.quantity',
+ increase: 'button.increase',
+ decrease: 'button.decrease',
+ input: 'input[type="number"]'
+ },
+ limits: {
+ hasLimit: '[data-limit]',
+ limit: '.limit',
+ current: '.current',
}
};
-
- // Initialize progress
- updateProgress(1);
}
+ initListeners() {
+ this.clickHandler = this.handleClick.bind(this);
+ this.changeHandler = this.handleChange.bind(this);
+ this.blurHandler = this.handleBlur.bind(this);
+ this.inputHandler = this.handleInput.bind(this);
+ this.submitHandler = this.handleSubmit.bind(this);
+ this.quantityClick = this.handleQuantityClick.bind(this);
+ this.repeaterClick = this.handleRepeaterClick.bind(this);
+ this.tagListClick = this.handleTagListClick.bind(this);
+ this.tagListInput = this.handleTagListInput.bind(this);
+ }
+ addFormListeners(form) {
+ form.addEventListener('click', this.clickHandler);
+ form.addEventListener('change', this.changeHandler);
+ form.addEventListener('input', this.inputHandler);
+ form.addEventListener('blur', this.blurHandler);
+ form.addEventListener('submit', this.submitHandler);
+ }
+ removeFormListeners(form) {
+ form.removeEventListener('click', this.clickHandler);
+ form.removeEventListener('change', this.changeHandler);
+ form.removeEventListener('input', this.inputHandler);
+ form.removeEventListener('blur', this.blurHandler);
+ form.removeEventListener('submit', this.submitHandler);
+ }
+ initStore() {
+ const store = window.jvbStore.register(
+ 'forms',
+ {
+ storeName: 'forms',
+ keyPath: 'id',
+ indexes: [
+ { name: 'src', keyPath: 'src'},
+ { name: 'timestamp', keyPath: 'timestamp' },
+ { name: 'formType', keyPath: 'type' }
+ ],
+ TTL: 7 * 24 * 60 * 1000, //7 days
+ });
+ this.store = store.forms;
- /**
- * Validate current step before allowing progression
- * Can be enhanced with custom validation rules
- */
- validateStep(section) {
- const fields = section.querySelectorAll('.field');
- let allValid = true;
+ this.store.subscribe((event, data)=> {
+ if (event === 'data-ready') {
+ let stored = this.store.getFiltered();
- 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;
+ let pending = stored.filter(form=> form.src === window.location.pathname);
+ for (let form of pending) {
+ this.showPendingNotification(form.id, form.changes);
+ }
+ } else if (event === 'operation-status' && data.status === 'completed') {
+ if (data.config) {
+ this.store.delete(data.config.id);
}
}
});
-
- 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 (event.target.closest('[data-ignore]')) {
- return;
- }
- if (this.subscribers.size > 0) {
- const target = event.target;
- const form = target.form || target.closest('form');
-
+ showPendingNotification(formId, changes) {
+ let form = this.forms.get(formId);
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);
- });
+ let element = form.element;
+ if (!element) {
+ console.warn(`Form element not found for: ${formId}`);
+ return;
}
- // Schedule auto-save if enabled
- if (formConfig.options.autoSave && !form.dataset.noautosave) {
- const delay = this.getDelayForField(target);
- this.scheduleSave(formConfig, delay);
- }
+ const notification = document.createElement('div');
+ notification.className = 'pendingChanges';
+ notification.innerHTML = `
+ <p>We noticed unsaved changes from last time. Would you like to restore them?</p>
+ <button class="restore" type="button" data-form-id="${formId}">Restore</button>
+ <button class="discard" type="button" data-form-id="${formId}">Discard</button>`;
+
+ element.insertBefore(notification, form.ui.status.status);
+
+ notification.querySelector('.restore').addEventListener('click', async () => {
+ this.isRestoring = true;
+
+ let theChanges = {['fields']: changes};
+ this.populate.populate(element, theChanges);
+ this.a11y.announce('Previous changes restored');
+
+ this.isRestoring = false;
+ notification.remove();
+ });
+
+ notification.querySelector('.discard').addEventListener('click', async () => {
+ await this.store.delete(formId);
+ this.a11y.announce('Previous changes discarded');
+ notification.remove();
+ });
+
}
- }
-
- handleFocus(event) {
- const target = event.target;
- if (target.matches('input, textarea, select')) {
- // Track focus for better UX
- this.currentFocus = 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 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 {
+ this.validators = {
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://'
+ message: 'Please enter a valid URL starting with https://'
},
phone: {
- pattern: /^[\d\s\-\+\(\)\.]+$/,
+ pattern: /^[\d\s\-+().]+$/,
message: 'Please enter a valid phone number'
},
number: {
@@ -898,1225 +235,1730 @@
}
};
}
- /**
- * 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;
+ validateField(input) {
+ const result = this.performValidation(input);
+ this.updateValidationUI(input, result);
+ return result.isValid;
}
+ performValidation(input) {
+ const field = input.closest('.field');
+ const value = this.getFieldCheckedValue(input);
- /**
- * 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;
+ return { isValid: true, message: '' };
}
- // Check required
- if (input.required && !value) {
- this.showError(fieldWrapper, 'This field is required');
- return false;
+ if (input.required) {
+ if (input.type === 'checkbox') {
+ if (!input.checked) {
+ return { isValid: false, message: 'This field is required' };
+ }
+ } else if (input.type === 'radio') {
+ const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`);
+ const anyChecked = Array.from(radioGroup).some(r => r.checked);
+ if (!anyChecked) {
+ return { isValid: false, message: 'Please select an option' };
+ }
+ } else if (!value) {
+ return { isValid: false, message: 'This field is required' };
+ }
}
- // Check HTML5 validity first
- if (input.checkValidity && !input.checkValidity()) {
- this.showError(fieldWrapper, input.validationMessage);
- return false;
+ if(input.checkValidity && !input.checkValidity()){
+ return {isValid: false, message: input.validationMessage};
}
- // Custom pattern validation from data attribute
- const pattern = fieldWrapper.dataset.pattern;
- if (pattern && value) {
- const regex = new RegExp(pattern);
+ if (value && Object.hasOwn(field.dataset, 'pattern')) {
+ const regex = new RegExp(field.dataset.pattern);
if (!regex.test(value)) {
- const message = fieldWrapper.dataset.validationMessage || 'Invalid format';
- this.showError(fieldWrapper, message);
- return false;
+ return {isValid: false, message: field.dataset.validationMessage || 'Invalid format'};
}
}
- // Type-specific validation
- const validateType = fieldWrapper.dataset.validate || input.type;
- if (validateType && this.validators[validateType]) {
- const validator = this.validators[validateType];
+ if (Object.hasOwn(field.dataset, 'validate') || input.type) {
+ const validator = this.validators[field.dataset.validate||input.type];
- if (validator.pattern && !validator.pattern.test(value)) {
- this.showError(fieldWrapper, validator.message);
- return false;
+ if (validator && validator.pattern && !validator.pattern.test(value)) {
+ return {isValid: false, message: validator.message};
}
- if (validator.test) {
- const result = validator.test(value, fieldWrapper);
+ if (validator && validator.test) {
+ const result = validator.test(value, field);
if (result !== true) {
- this.showError(fieldWrapper, result);
- return false;
+ return {isValid: false, message: result};
}
}
}
- // All validations passed
- this.showSuccess(fieldWrapper);
- return true;
+ return {isValid: true, message: ''};
}
-
- /**
- * 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 = '';
+ updateValidationUI(input, result) {
+ if (result.isValid) {
+ this.showSuccess(input, result.message);
+ } else {
+ this.showError(input, result.message);
}
}
- /**
- * 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) {
+ handleClick(e) {
+ let form = this.getForm(e.target);
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;
+ const itemAction = window.targetCheck(e, '[data-action]');
+ if (itemAction) {
+ let action = itemAction.dataset.action;
+ switch (action) {
+ case 'clear-form':
+ this.store.delete(form.id);
+ form.element.reset();
+ form.ui.status.status.hidden = true;
+ this.a11y.announce('Form cleared, starting fresh');
+ break;
+ case 'dismiss-restore':
+ form.ui.status.status.hidden = true;
+ break;
}
- });
-
- 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;
+ handleChange(e) {
+ if (e.target.closest('[data-ignore]') || this.isRestoring) return;
+
+ let field = this.getField(e.target);
+
+ //Dependencies
+ if (this.dependencies.has(field.dataset.field)) {
+ let dependency = this.dependencies.get(field.dataset.field);
+ dependency.items.forEach(item => {
+ this.checkFieldDependency(item, field.dataset.field);
+ });
}
- // 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) {
+ if (Object.hasOwn(field.dataset, 'repeater-id') || Object.hasOwn(field.dataset,'tag-list-id')) {
+ this.updateCollectionField(field);
return;
}
- this.scheduleSave(this.forms.get(form.dataset.id));
+ let form = this.getForm(e.target);
+ this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
}
- async autosave(formConfig) {
- const formData = this.collectFormData(formConfig.element);
+ handleBlur(e) {
+ if (e.target.closest('[data-ignore]') || this.isRestoring) return;
+ let form = this.getForm(e.target);
+ if (!form) return;
- 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');
- });
+ let field = this.getField(e.target);
+ let fieldName = field.dataset.field;
+ window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
+ this.validateField(e.target);
- // 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
- });
+ this.updateItem(fieldName, this.getFieldValue(e.target), form);
}
- /**
- * Check if form has unsaved changes
- */
- hasUnsavedChanges(formId) {
- const formConfig = this.forms.get(formId);
- if (!formConfig) return false;
+ handleInput(e){
+ let form = this.getForm(e.target);
+ if (!form) return;
- // Check if there are pending operations
- if (formConfig.operations?.size > 0) return true;
+ let field = this.getField(e.target);
+ if (!field) return;
- // Check if current data differs from snapshot
- const currentData = this.collectFormData(formConfig.element);
- const changes = this.getChangedFields(formConfig.lastSnapshot, currentData);
+ const input = e.target; // Capture reference
+ const fieldName = field.dataset.field;
- return Object.keys(changes).length > 0;
- }
+ // Show pending status regardless of cache
+ this.showFormStatus(form.id, 'pending');
- 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')
+ // Debounce validation
+ window.debouncer.schedule(
+ `form:${form.id}:validate:${fieldName}`,
+ () => this.validateField(input),
+ 500
);
}
+ async handleSubmit(e) {
+ let form = this.getForm(e.target);
+ if (!form) return;
+
+ if (this.subscribers.size > 0) {
+ e.preventDefault();
+
+ if (form.options.cache) {
+ this.cancelBackup();
+ await this.backup();
+ const storedData = await this.store.get(form.id);
+
+ this.notify('form-submit', {
+ config: form,
+ data: storedData.changes
+ });
+ } else {
+ this.notify('form-submit', {
+ config: form,
+ data: this.changes.get(form.id)?.changes??{},
+ });
+ }
+
+ }
+
+ if (form.options.showSummary) {
+ const storedData = await this.store.get(form.id);
+ this.showSummary({config: form, changes: storedData?.changes});
+ }
+ }
+
/**
- * Format field value based on type
+ * Updates the item, schedules caching if
+ * @param name
+ * @param value
+ * @param form
*/
- formatFieldValue(value, type, form) {
+ updateItem(name, value, form) {
+ if (!this.changes.has(form.id)) {
+ this.changes.set(form.id, {
+ id: form.id,
+ timestamp: Date.now(),
+ src: window.location.pathname,
+ changes: {},
+ });
+ }
+ let changes = this.changes.get(form.id);
+ changes.changes[name] = value;
+ this.changes.set(form.id, changes);
+ if (form.options.cache) {
+ this.scheduleBackup();
+ }
+ }
+
+ scheduleBackup() {
+ window.debouncer.schedule(
+ `form_changes`,
+ async () => {
+ if (this.changes.size > 0) {
+ await this.backup();
+ }
+ },
+ 2000
+ );
+ }
+ cancelBackup() {
+ window.debouncer.cancel('form_changes');
+ }
+ async backup() {
+ // Merge with existing stored data
+ const toSave = new Map();
+
+ for (let [formId, newData] of this.changes.entries()) {
+ const stored = await this.store.get(formId);
+
+ if (stored) {
+ // Merge changes
+ toSave.set(formId, {
+ ...stored,
+ ...newData,
+ changes: {
+ ...stored.changes,
+ ...newData.changes
+ },
+ timestamp: Date.now()
+ });
+ } else {
+ toSave.set(formId, newData);
+ }
+ }
+
+ await this.store.saveMany(toSave);
+
+ for (let formId of this.changes.keys()) {
+ this.showFormStatus(formId, 'autosaved');
+ }
+ this.changes.clear();
+ }
+
+ saveCache(formId) {
+ if (!this.changes.has(formId)) return;
+ let changes = this.changes.get(formId);
+ if (changes.size === 0) return;
+ this.store.save(changes).then(()=>{});
+ this.changes.delete(formId);
+ }
+
+
+ /**
+ * Register a form for handling
+ * @param {HTMLElement} form
+ * @param {object} options
+ */
+ registerForm(form, options) {
+ //Bail if form already registered
+ if (Object.hasOwn(form.dataset, 'formId') && this.forms.has(form.dataset.formId)) return;
+
+ if (!Object.hasOwn(form.dataset, 'formId')) {
+ form.dataset.formId = window.generateID('form_');
+ }
+ const formId = form.dataset.formId;
+
+ this.addFormListeners(form);
+
+ const config = {
+ element: form,
+ id: formId,
+ status: '',
+ options: {
+ autoUpload: options.autoUpload??false,
+ imageMeta: options.imageMeta??true,
+ delay: options.delay??1500,
+ endpoint: options.save??form.dataset.save??'',
+ showStatus: options.showStatus??true,
+ showSummary: options.showSummary??false,
+ cache: options.cache??true,
+ ignore: options.ignore??[]
+ },
+ ui: window.uiFromSelectors(this.selectors.forms, form)
+ };
+
+ this.initializeFields(form, config);
+ this.forms.set(formId, config);
+
+ return config;
+ }
+ clearForm(formId) {
+ const config = this.forms.get(formId);
+ if (!config) return;
+
+ if (config.unsubscribeTabs) {
+ config.unsubscribeTabs();
+ }
+ if(config.tabs) {
+ window.jvbTabs.removeTab(config.element);
+ }
+
+ if (config.cache && this.changes.has(formId)) this.saveCache(formId);
+
+ // Cleanup items
+ for (let [id, input] of this.inputs.entries()) {
+ if (input.form === formId) {
+ this.inputs.delete(id);
+ }
+ }
+ // Clean up dependencies for this form
+ this.dependencies.forEach((dependency, fieldName) => {
+ dependency.items = dependency.items.filter(item => item.form !== formId);
+
+ // Remove the dependency entry entirely if no items left
+ if (dependency.items.length === 0) {
+ this.dependencies.delete(fieldName);
+ }
+ });
+
+ if (Object.hasOwn(config, 'hasQuill') && this.quillInstances.has(formId)) {
+ const instances = this.quillInstances.get(formId);
+ instances.forEach(quillInstance => {
+ // Disable the editor
+ quillInstance.disable();
+
+ // Remove all event listeners
+ quillInstance.off('text-change');
+ quillInstance.off('selection-change');
+
+ // Get the container elements
+ const container = quillInstance.container.parentElement;
+ const toolbar = container?.querySelector('.ql-toolbar');
+
+ // Remove toolbar
+ if (toolbar) {
+ toolbar.remove();
+ }
+
+ // Clear the editor content
+ quillInstance.setText('');
+
+ // Remove container
+ if (container && container.classList.contains('editor-container')) {
+ const textarea = container.nextElementSibling;
+ if (textarea?.tagName === 'TEXTAREA') {
+ textarea.style.display = '';
+ }
+ container.remove();
+ }
+ });
+
+ this.quillInstances.delete(formId);
+ }
+ let checks = {
+ repeater: this.repeaters,
+ tagList: this.tagLists,
+ charLimit: this.charLimits,
+ quantity: this.quantityFields
+ };
+ for (let [type, check] of Object.entries(checks)) {
+ if (check.size === 0) continue;
+ let hasAny = Array.from(check.values()).filter(item => item.form === formId);
+ if (hasAny.length > 0) {
+ hasAny.forEach(item => {
+ switch (type) {
+ case 'repeater':
+ this.removeRepeaterListeners(item.element);
+ break;
+ case 'tagList':
+ this.removeTagListListeners(item.element);
+ break;
+ case 'charLimit':
+ this.removeCharacterLimitListeners(item.element);
+ break;
+ case 'quantity':
+ this.removeQuantityListeners(item.element);
+ break;
+ }
+
+ if (check.has(item.id)) {
+ check.delete(item.id);
+ }
+ });
+ }
+ }
+
+
+ this.removeFormListeners(config.element);
+ this.forms.delete(formId);
+
+ window.debouncer.cancel(`form_changes`);
+ }
+ defineSummaryTemplate() {
+ this.summaryTemplate = true;
+ let form = this;
+ this.templates.define(
+ 'formSummary',
+ {
+ refs: {
+ result: '.result',
+ h3: 'h3',
+ p: 'p',
+ },
+ setup({ el, refs, manyRefs, data }) {
+ const skipFields = ['sendAll', ...data.config.options.ignore??[]];
+
+ for (let [key, value] of Object.entries(data.changes)) {
+ if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
+
+ let input = Array.from(form.inputs.values())
+ .find(temp => temp.field?.dataset.field === key);
+ if (!input) continue;
+
+ let entry = refs.result.cloneNode(true);
+ let title = entry.querySelector('h3');
+ let p = entry.querySelector('p');
+
+ // Get field label - prioritize legend for fieldsets, then label
+ const legend = input.field?.querySelector('legend');
+ title.textContent = legend
+ ? legend.textContent.replace('*', '').trim()
+ : input.ui.label?.textContent.replace('*', '').trim();
+
+
+ const formattedValue = form.formatValueForSummary(value, input);
+
+ if (formattedValue instanceof HTMLElement) {
+ // If it's an HTML element (repeater, tag-list, etc.), replace <p>
+ p.replaceWith(formattedValue);
+ } else {
+ // If it's a string, set text content
+ p.textContent = formattedValue;
+ }
+
+ el.append(entry);
+ }
+ let uploads = data.config?.element?.querySelectorAll('[data-upload-field]');
+ if (uploads) {
+ uploads.forEach(upload => {
+ let label = upload.querySelector('h2')?.textContent??'Upload:';
+ let imgs = upload.querySelectorAll('.item-grid.preview img');
+ let field = refs.result.cloneNode(true);
+ if (imgs) {
+ let entry = refs.result.cloneNode(true);
+ let title = field.querySelector('h3');
+ let p = field.querySelector('p');
+ p?.remove();
+ if (title) title.textContent = label;
+ imgs.forEach(img => {
+ img = img.cloneNode(true);
+ entry.append(img);
+ });
+ el.append(entry);
+ }
+ });
+ }
+
+ refs.result?.remove();
+ data.config.element.after(el);
+ window.fade(data.config.element, false);
+ }
+ }
+ );
+ }
+
+
+ initializeFields(container, config = null) {
+ const fieldHandlers = {
+ '[data-editor]': () => this.checkForQuill(container,config),
+ 'div.quantity': () => this.checkForQuantity(container),
+ '.repeater': () => this.checkForRepeaters(container, config),
+ '.field.tag-list': () => this.checkForTagLists(container),
+ '[data-depends-on]': () => this.checkForConditionalFields(container),
+ '[data-limit]': () => this.checkForCharacterLimits(container),
+ '[data-uploader],[data-upload-field]': () => this.checkForImageUploads(container, config),
+ 'nav.tabs': () => this.checkForTabs(container, config),
+ '[data-type="selector"]': () => this.checkForSelectors(container)
+ };
+
+ for (const [selector, handler] of Object.entries(fieldHandlers)) {
+ if (container.querySelector(selector)) {
+ handler();
+ }
+ }
+
+ let inputs = Array.from(container.querySelectorAll(this.inputSelectors));
+ inputs.map(input => {
+ this.getItem(input, config?.id);
+ });
+ }
+ checkForQuill(form, config) {
+ if (!form.querySelector('[data-editor]')) return;
+ if (config && !Object.hasOwn(config, 'hasQuill')){
+ config.hasQuill = true;
+ this.forms.set(config.id, config);
+ }
+
+ if (!this.quillInstances.has(config.id)) {
+ this.quillInstances.set(config.id, new Set());
+ }
+
+ const instances = window.jvbQuill(form);
+ instances.forEach(instance => {
+ this.quillInstances.get(config.id).add(instance);
+ });
+ }
+ checkForQuantity(form) {
+ if (!form.querySelector(this.selectors.number.number)) return;
+ form.querySelectorAll(this.selectors.number.number).forEach(num => {
+ let config = {
+ id: window.generateID('quant'),
+ form: form.dataset.formId,
+ ui: window.uiFromSelectors(this.selectors.number, num),
+ element: num
+ };
+ num.dataset.numId = config.id;
+ this.quantityFields.set(config.id, config);
+ this.addQuantityListeners(num);
+ });
+ }
+ addQuantityListeners(el) {
+ el.addEventListener('click', this.quantityClick);
+ }
+ removeQuantityListeners(el) {
+ el.removeEventListener('click', this.quantityClick);
+ }
+ handleQuantityClick(e) {
+ let conf = this.quantityFields.get(e.target.closest('[data-num-id]')?.dataset.numId);
+ if(!conf) return;
+ let change = 0;
+ if (conf.increase.contains(e.target)) {
+ change++;
+ } else if (conf.decrease.contains(e.target)) {
+ change--;
+ }
+ if (change === 0) return;
+ let field = this.getField(e.target);
+ let step = conf.input.step;
+ 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 = (conf.input.value === '') ? 0 : parseFloat(conf.input.value);
+ conf.input.value = (value + (step * change));
+
+ value = parseFloat(conf.input.value);
+
+ if (conf.input.min && value < conf.input.min) {
+ conf.input.value = conf.input.min;
+ conf.decrease.disabled = true;
+ } else if (conf.input.max && value > conf.input.max) {
+ conf.input.value = conf.input.max;
+ conf.increase.disabled = true;
+ } else {
+ if (conf.decrease.disabled) conf.decrease.disabled = false;
+ if (conf.increase.disabled) conf.increase.disabled = false;
+ }
+ }
+ checkForRepeaters(form) {
+
+ if (!form.querySelector(this.selectors.repeater.repeater)) return;
+
+ form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => {
+ let config = {
+ id: repeater.querySelector('template').className??window.generateID('repeater'),
+ ui: window.uiFromSelectors(this.selectors.repeater, repeater),
+ form: form.dataset.formId,
+ element: repeater,
+ field: this.getField(repeater),
+ sortable: false,
+ };
+
+ if (!config.ui.add) return;
+
+ let template = repeater.querySelector('template');
+ this.templates.define(
+ template.className,
+ {
+ manyRefs: {
+ inputs: this.inputSelectors,
+ },
+ setup({el, refs, manyRefs, data}) {
+ let index = config.ui.items?.children?.length??0;
+ el.dataset.index = index;
+
+
+ manyRefs.inputs?.forEach(input => {
+ window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el);
+ });
+ }
+ },
+ );
+
+ if (window.Sortable) {
+ config.sortable = new Sortable(repeater, {
+ handle: this.selectors.repeater.header,
+ animation: 150,
+ onEnd: () => {
+ this.reindexList(repeater);
+ }
+ });
+ }
+ repeater.dataset.repeaterId = config.id;
+ this.addRepeaterListeners(repeater);
+ this.repeaters.set(config.id, config);
+ });
+
+ }
+ addRepeaterListeners(el) {
+ el.addEventListener('click', this.repeaterClick);
+ }
+ removeRepeaterListeners(el) {
+ el.removeEventListener('click', this.repeaterClick);
+ }
+ handleRepeaterClick(e) {
+ if (e.target.matches(this.selectors.repeater.add)) {
+ console.log('Add Repeater Row');
+ this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
+ } else if (e.target.matches(this.selectors.repeater.remove)) {
+ console.log('Remove Repeater Row');
+ this.removeRepeaterRow(e.target.closest('[data-index]'));
+ }
+ }
+ addRepeaterRow(repeater) {
+ let data = {};
+ data.repeater = repeater;
+ repeater.append(this.templates.create(repeater.dataset.repeaterId, data));
+ this.initializeFields(repeater, this.getField(repeater).config??{});
+ this.a11y.announce('Row added');
+ }
+ removeRepeaterRow(row) {
+ let repeater = row.closest('[data-repeater-id]');
+ row.remove();
+ this.reindexList(repeater);
+ this.a11y.announce('Row removed');
+ }
+ checkForTagLists(form) {
+ form.querySelectorAll(this.selectors.tagList.tagList)?.forEach(field=> {
+ let config = {
+ id: field.querySelector('template').className??window.generateID('tagList'),
+ ui: window.uiFromSelectors(this.selectors.tagList, field),
+ element: field,
+ form: form.dataset.formId,
+ format: field.dataset.tagFormat??'first_field'
+ };
+ if (!config.ui.input || !config.ui.add || !config.ui.items) return;
+
+ field.dataset.tagListId = config.id;
+
+ let template = field.querySelector('template');
+ this.templates.define(
+ template.className,
+ {
+ refs: {
+ label: this.selectors.tagList.label,
+ },
+ manyRefs: {
+ inputs: this.inputSelectors,
+ },
+ setup({el, refs, manyRefs, data}) {
+ let index = config.ui.items?.children?.length??0;
+ el.dataset.index = index;
+ manyRefs.inputs?.forEach(input => {
+ let wrapper = window.closest('.tag-item');
+ window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper)
+ });
+
+ if (refs.label) {
+ refs.label.textContent = data.label;
+ }
+ }
+ },
+ );
+
+ this.tagLists.set(config.id, config);
+ this.addTagListListeners(field);
+ });
+
+ }
+ addTagListListeners(el) {
+ el.addEventListener('click', this.tagListClick);
+ el.addEventListener('keypress', this.tagListInput, {passive: true})
+ }
+ removeTagListListeners(el) {
+ el.removeEventListener('click', this.tagListClick);
+ el.removeEventListener('keypress', this.tagListInput)
+ }
+
+ handleTagListClick(e) {
+ if (e.target.matches(this.selectors.tagList.add)) {
+ this.addTagListItem(e.target.closest('[data-tag-list-id]'));
+ } else if (e.target.matches(this.selectors.tagList.remove)) {
+ this.removeTagListItem(e.target.closest(this.selectors.tagList.remove));
+ }
+ }
+ addTagListItem(tagList) {
+ let config = this.tagLists.get(tagList.dataset.tagListId);
+ if (!config) return;
+
+ let data = {};
+ let hasValue = false;
+
+ for (let input of config.ui.inputs) {
+ this.validateField(input);
+ const fieldName = input.name.replace('new_','');
+ const value = this.getFieldValue(input);
+ if (value) hasValue = true;
+ data[fieldName] = value;
+
+ //clear values and validation
+ if (['checkbox', 'radio'].includes(input.type)) {
+ input.checked = false;
+ } else {
+ input.value = '';
+ }
+ this.clearValidation(input);
+ }
+
+ if (!hasValue) {
+ this.a11y.announce('Please fill in at least one field');
+ config.ui.inputs[0].focus();
+ return;
+ }
+
+ let label;
+ switch (config.format) {
+ case 'first_field':
+ label = Object.values(data)[0];
+ break;
+ case 'all_fields':
+ label = Object.values(data).join(', ');
+ break;
+ default:
+ if (format.includes('{')) {
+ let label = config.format;
+ for (const [key, value] of Object.entries(data)) {
+ label = label.replace(`{${key}}`, value);
+ }
+ } else {
+ label = data[config.format]??Object.values(data)[0];
+ }
+ break;
+ }
+
+ let newItem = this.templates.create(tagList.dataset.tagListId, {
+ label: label
+ });
+
+ const index = config.ui.items?.children?.length ?? 0;
+ newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => {
+ const fieldKey = input.dataset.field;
+ input.name = `${config.element.field}:${index}:${fieldKey}`;
+ input.value = data[fieldKey] || '';
+ });
+
+ config.ui.items.append(newItem);
+ config.ui.inputs[0]?.focus();
+
+ this.updateCollectionField(tagList);
+
+ this.a11y.announce('Item added');
+ }
+ removeTagListItem(tag) {
+ let tagList = tag.closest('[data-tag-list-id]');
+ tag.remove();
+ this.reindexList(tagList);
+ this.a11y.announce('Item removed');
+ }
+ handleTagListInput(e) {
+ let target = e.target;
+ let field = target.closest('[data-tag-list-id]');
+ if (!field) return;
+ let config = this.tagLists.get(field.dataset.tagListId);
+ if (!config) return;
+
+ if (e.key === 'Enter') {
+ if (target === config.ui.inputs[config.ui.inputs.length - 1]) {
+ e.preventDefault();
+ this.addTagListItem(target.closest('[data-tag-list-id]'));
+ } else {
+ e.preventDefault();
+ let index = config.ui.inputs.indexOf(target);
+ config.ui.inputs[index+1].focus();
+ }
+ }
+
+ }
+
+ checkForConditionalFields(form) {
+ form.querySelectorAll(this.selectors.dependsOn).forEach( field => {
+ const dependsOn = field.dataset.dependsOn;
+ const requiredValue = field.dataset.dependsValue;
+ const operator = field.dataset.dependsOperatior??'==';
+
+ if (!this.dependencies.has(dependsOn)) {
+ let element = document.querySelector(`[field="${dependsOn}"]`);
+ if (element) {
+ this.dependencies.set(dependsOn, {
+ element: element,
+ items: []
+ });
+ }
+ }
+ let dependency = this.dependencies.get(dependsOn);
+ dependency.items.push({
+ field: field,
+ form: form.dataset.formId,
+ requiredValue: requiredValue,
+ operator: operator
+ });
+ this.dependencies.set(dependsOn, dependency);
+ this.checkFieldDependency(dependency, dependsOn);
+ });
+ }
+ checkFieldDependency(dependentField, controlFieldName) {
+ const controlField = this.dependencies.get(controlFieldName);
+ if (!controlField) return;
+
+ const controlValue = this.getFieldCheckedValue(controlField.element);
+ const shouldShow = this.evaluateCondition(
+ controlValue,
+ dependentField.requiredValue,
+ dependentField.operator
+ );
+
+ this.toggleFieldVisibility(dependentField.field, shouldShow);
+ }
+ 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;
+ }
+ }
+ 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;
+ }
+ });
+ }
+ checkForCharacterLimits(form) {
+ if (!form.querySelector(this.selectors.limits.hasLimit)) return;
+ this.countUpdaters = this.updateCount.bind(this);
+
+ form.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach(input => {
+ let id = window.generateID('limit');
+ input.dataset.charLimitId = id;
+ let config = {
+ element: input,
+ form: form.dataset.formId,
+ ui: window.uiFromSelectors(this.selectors.limits, input.closest('.field'))
+ };
+ config.ui.limit.textContent = input.dataset.limit;
+ this.charLimits.set(id, config);
+
+ this.addCharacterLimitListeners(input);
+ });
+
+ }
+ addCharacterLimitListeners(input) {
+ input.addEventListener('input', this.countUpdaters, {passive: true});
+ }
+ removeCharacterLimitListeners(input) {
+ input.removeEventListener('input', this.countUpdaters, {passive: true});
+ }
+ updateCount(e) {
+ let target = e.target;
+ let config = this.charLimits.get(target.dataset.charLimitId);
+ if (!config) return;
+ let length = target.value.length;
+ let limit = target.dataset.limit;
+ if (config.ui.current) {
+ config.ui.current.textContent = length;
+ config.ui.current.classList.toggle('exceeded', length >= limit);
+ }
+ if (length > limit) {
+ target.value = target.value.slice(0, limit);
+ }
+ }
+ checkForImageUploads(form, config) {
+ window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta);
+ }
+
+ checkForTabs(form, config) {
+ if (window.jvbTabs && form.querySelector('nav.tabs')) {
+ config.tabs = window.jvbTabs.registerTab(form, {
+ preCheck: (section, tabConfig) => {
+ return this.validateStep(section, config);
+ }
+ });
+ config.ui.tabs = window.uiFromSelectors(this.selectors.tabs, form);
+ config.ui.tabs.sections = Array.from(form.querySelectorAll(this.selectors.tabs.sections));
+ config.ui.tabs.inputs = {};
+ config.ui.tabs.sections.forEach(section => {
+ config.ui.tabs.inputs[section.dataset.tab] = Array.from(section.querySelectorAll(this.inputs));
+ });
+ config.ui.tabs.buttons = Array.from(form.querySelectorAll(this.selectors.tabs.buttons));
+
+ config.unsubscribeTabs = window.jvbTabs.subscribe((event, data) => {
+ if (event === 'tab-switched') {
+ if (config.ui.tabs.progress) {
+ const section = config.ui.tabs.sections.filter(section => section.dataset.tab === data.current)[0]??false;
+ if (!section) return;
+ const step = section.dataset.step;
+ const total = config.ui.sections.length;
+
+ window.showProgress(
+ config.ui.tabs.progress,
+ step,
+ total
+ );
+ }
+ }
+ });
+ this.forms.set(config.id, config);
+ }
+ }
+ validateStep(section, config) {
+ const formId = section.closest('[data-form-id]')?.dataset.formId;
+ if (!formId) return true;
+
+ const form = this.forms.get(formId);
+ if (!form) return true;
+
+ const inputs = Array.from(this.inputs.values())
+ .filter(item =>
+ item &&
+ item.form === formId &&
+ item.section === section.dataset.tab &&
+ !item.element.closest('[hidden]')
+ );
+
+ return inputs.every(item => this.validateField(item.element) === true);
+ }
+ checkForSelectors(form) {
+ if (window.jvbSelector) window.jvbSelector.scanExistingFields(form);
+ }
+ /**
+ * Mainly for repeaters or taglist
+ * @param {HTMLElement} container
+ */
+ reindexList(container) {
+ const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
+
+ Array.from(container.children).forEach((item, index) => {
+ item.dataset.index = `${index}`;
+
+ // Find ALL inputs within this item, not just direct children
+ const inputs = item.querySelectorAll('input, select, textarea');
+
+ inputs.forEach(input => {
+ // Skip inputs that shouldn't be re-indexed (like file inputs)
+ if (input.type === 'file') return;
+
+ // Get the field name from the input's data-field or name
+ const inputField = input.dataset.field || input.name.split(':').pop();
+
+ // Re-prefix with the new index, passing item as wrapper
+ window.prefixInput(
+ input,
+ `${fieldName}:${index}:`,
+ item // Pass the item as wrapper for label lookup
+ );
+ });
+ });
+
+
+ this.updateCollectionField(container);
+ }
+
+ /**
+ * Update the entire repeater/tagList field data
+ * Call this whenever rows are added, removed, or reordered
+ */
+ updateCollectionField(element) {
+ const field = element.closest('[data-field]');
+ if (!field) return;
+
+ const fieldType = field.dataset.fieldType;
+ if (!['repeater', 'tag-list'].includes(fieldType)) return;
+
+ const form = this.getForm(element);
+ if (!form) return;
+
+ // Get all current data for the collection
+ const value = this.getFieldValue(field.querySelector('input, select, textarea'));
+ this.updateItem(field.dataset.field, value, form);
+ }
+ /**********************************************************************
+ VALIDATION
+ **********************************************************************/
+ //text, email, url, tel, date, time, datetime, number
+ //select, checkbox, radio, true_false
+ //textarea
+ //repeater: subfields validation; no submission until all required are entered
+ //tag fields: similar to repeater; each separate field is its own hidden field
+ //upload: comma separated ints
+ //selector: comma separated ints
+ //location: hidden inputs for address, lat, lng, street, city, province, postal_code, country
+
+ clearValidation(input) {
+ let field = this.getField(input);
+ if (!field) return;
+ let item = this.getItem(input);
+ if (!item) return;
+
+ field.classList.remove('has-error', 'has-success');
+
+ if (item.ui.success) item.ui.success.hidden = true;
+ if (item.ui.error) item.ui.error.hidden = true;
+ if (item.ui.message) {
+ item.ui.message.hidden = true;
+ item.ui.message.textContent = '';
+ }
+ }
+
+ showError(input, message = 'Invalid field') {
+ let field = this.getField(input);
+ if (!field) return;
+ let item = this.getItem(input);
+ if (!item) return;
+
+ field.classList.remove('has-success');
+ field.classList.add('has-error');
+
+ if (item.ui.success) item.ui.success.hidden = true;
+ if (item.ui.error) item.ui.error.hidden = true;
+ if (item.ui.message) {
+ item.ui.message.hidden = false;
+ item.ui.message.textContent = message;
+ }
+ }
+
+ showSuccess(input, message = '') {
+ let field = this.getField(input);
+ if (!field) return;
+ let item = this.getItem(input);
+ if (!item) return;
+
+ field.classList.remove('has-error');
+ field.classList.add('has-success');
+
+ if (item.ui.success) item.ui.success.hidden = false;
+ if (item.ui.error) item.ui.error.hidden = true;
+
+ if (item.ui.message) {
+ item.ui.message.hidden = message=== '';
+ item.ui.message.textContent = message;
+ }
+ }
+
+ handleFormSuccess(form, data) {
+ // Clear previous errors
+ form.querySelectorAll('.error-message').forEach(el => el.remove());
+ form.querySelectorAll('.field-error').forEach(el =>
+ el.classList.remove('field-error')
+ );
+
+ // Add success class to form
+ form.classList.add('form-success');
+
+ // Show success message if provided
+ if (data.message) {
+ const success = document.createElement('div');
+ success.className = 'form-success-message success-message';
+ success.textContent = data.message;
+ form.insertBefore(success, form.firstChild);
+
+ const icon = window.getIcon?.('check-circle');
+ if (icon) {
+ icon.classList.add('success-icon');
+ success.prepend(icon);
+ }
+ }
+
+ // If there's a title/description (for registration success)
+ if (data.title || data.description) {
+ const successBox = document.createElement('div');
+ successBox.className = 'success-box';
+
+ if (data.title) {
+ const title = document.createElement('h3');
+ title.textContent = data.title;
+ successBox.appendChild(title);
+ }
+
+ if (data.description) {
+ const descriptions = Array.isArray(data.description)
+ ? data.description
+ : [data.description];
+
+ descriptions.forEach(desc => {
+ const p = document.createElement('p');
+ p.textContent = desc;
+ successBox.appendChild(p);
+ });
+ }
+
+ form.insertBefore(successBox, form.firstChild);
+ }
+
+ // DELETE CACHED FORM DATA ON SUCCESS
+ if (form.dataset.formId) {
+ this.store.delete(form.dataset.formId).catch(err => {
+ console.warn('Failed to clear form cache:', err);
+ });
+
+ // Clear form config dirty state
+ const formConfig = this.forms.get(form.dataset.formId);
+ if (formConfig) {
+ formConfig.isDirty = false;
+ formConfig.lastSaved = Date.now();
+ formConfig.data = {}; // Clear cached data
+ }
+ }
+
+ // Announce success for accessibility
+ if (window.jvbA11y) {
+ window.jvbA11y.announce(data.message || 'Form submitted successfully');
+ }
+ }
+
+ handleFormError(form, data) {
+ // Clear all previous errors
+ form.querySelectorAll('.error-message').forEach(el => el.remove());
+ form.querySelectorAll('.field-error, .has-error').forEach(el => {
+ el.classList.remove('field-error', 'has-error');
+ });
+
+ // Clear validation states using existing method
+ form.querySelectorAll('.field').forEach(fieldWrapper => {
+ this.clearValidation(fieldWrapper);
+ });
+
+ // Handle field-specific errors
+ if (data.field) {
+ const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`);
+ if (fieldWrapper) {
+ // Use existing showError method for consistency
+ this.showError(fieldWrapper, data.message);
+
+ // Mark as touched so validation persists
+ this.touchedFields.add(data.field);
+
+ // Scroll to error
+ fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+ // Focus the input for better UX
+ const input = fieldWrapper.querySelector('input, textarea, select');
+ if (input) {
+ input.focus();
+ }
+ }
+ } else {
+ // General form error (not field-specific)
+ const error = document.createElement('div');
+ error.className = 'form-error error-message';
+ error.textContent = data.message;
+
+ // Add icon for consistency
+ const icon = window.getIcon?.('close-circle');
+ if (icon) {
+ icon.classList.add('error-icon');
+ error.prepend(icon);
+ }
+
+ form.insertBefore(error, form.firstChild);
+
+ // Scroll to top to show the error
+ form.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+
+ // Announce error for accessibility
+ if (window.jvbA11y) {
+ const announcement = data.field
+ ? `Error in ${data.field}: ${data.message}`
+ : `Form error: ${data.message}`;
+ window.jvbA11y.announce(announcement);
+ }
+
+ // Trigger custom event
+ form.dispatchEvent(new CustomEvent('jvb-form-error', {
+ detail: data
+ }));
+ }
+
+ /**********************************************************************
+ STATUS
+ **********************************************************************/
+ showFormStatus(formId, status, message ='') {
+ let form = this.forms.get(formId);
+ if (!form || !form.options.showStatus || !form.ui?.status?.status) return;
+ if (form.status === status) return;
+
+
+ form.status = status;
+ form.ui.status.status.hidden = false;
+ form.ui.status.status.classList.toggle('loading', ['uploading', 'saving'].includes(status));
+
+ form.ui.status.message.textContent = message === '' ? this.getDefaultMessage(status) : message;
+
+ form.ui.status.icon.className = 'icon icon-'+this.getDefaultIcon(status);
+ setTimeout(()=> form.ui.status.status.hidden = true, (status === 'submitted') ? 3000 : 10000);
+ }
+ getDefaultMessage(status) {
+ const messages = {
+ 'saving': 'Saving changes...',
+ 'autosaved': 'Changes saved locally. Submit form to send to server.',
+ 'uploading': 'Uploading your form to server',
+ 'submitted': 'Successfully sent to server',
+ 'pending': 'Unsaved changes',
+ 'restored': 'Welcome back! We\'ve restored your previous entry.',
+ 'error': 'Failed to save changes. Refresh and try again?',
+ 'offline': 'Changes will be saved when online'
+ };
+ return messages[status]??status;
+ }
+ getDefaultIcon(status) {
+ const icons = {
+ 'autosaved': 'check-circle',
+ 'submitted': 'check-circle',
+ 'restored': 'history',
+ 'error': 'close-circle',
+ 'offline': 'cloud-slash',
+ 'pending': 'exclamation-mark'
+ }
+ return icons[status]??'';
+ }
+
+
+ /**********************************************************************
+ SUMMARY
+ **********************************************************************/
+ showSummary(data) {
+ let summary = this.templates.create('formSummary', data);
+ data.config.element.after(summary);
+ window.fade(data.config.element, false);
+ }
+ /**********************************************************************
+ UTILITY
+ **********************************************************************/
+ getForm(element) {
+ let form = element.closest('[data-form-id]');
+ let id = form.dataset.formId;
+ if (!id) return false;
+ let config = this.forms.get(id);
+ if (!config) return false;
+ return config;
+ }
+ getField(element) {
+ return element.closest('[data-field]');
+ }
+ getFieldType(element) {
+ let field = this.getField(element);
+ if (!field) return;
+ return field.dataset.fieldType;
+ }
+ getFieldValue(element) {
+ let type = this.getFieldType(element);
+ let conf = this.getItem(element);
+ let fieldName = conf.field?.dataset.field??false;
+ if (!fieldName) return false;
+
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);
+ return this.getRepeaterValue(element, conf);
+
+ case 'tag-list':
+ return this.getTagListValue(element, conf);
case 'group':
- return this.formatGroupValue(value);
+ //Do we actually need anything here? I think each subfield just
+ break;
case 'location':
- return this.formatLocationValue(value);
+ return this.getLocationValue(element, conf);
- case 'file':
- case 'image':
- return this.formatFileValue(value);
+ case 'selector':
+ case 'upload':
+ return this.getHiddenInputValue(element, conf, fieldName);
- case 'number':
- return this.formatNumber(value);
+ case 'true-false':
+ return element.value === '1'||element.value === 'on'||element.value ==='true';
+ case 'checkbox':
+ // Handle multi-checkbox (name ends with [])
+ if (element.name.endsWith('[]')) {
+ return this.getCheckboxGroupValue(element, conf);
+ }
+ return element.checked ? element.value : '';
+ default:
+ return element.value;
+ }
+ }
- case 'email':
- return `<a href="mailto:${value}">${value}</a>`;
+ /**
+ * Get all checked values for a checkbox group
+ */
+ getCheckboxGroupValue(element, conf) {
+ if (!conf.checkboxGroup) {
+ conf.checkboxGroup = conf.field?.querySelectorAll(`input[type="checkbox"][name="${element.name}"]`);
+ this.saveItem(conf);
+ }
- case 'url':
- return `<a href="${value}" target="_blank" rel="noopener">${value}</a>`;
+ return Array.from(conf.checkboxGroup)
+ .filter(cb => cb.checked)
+ .map(cb => cb.value);
+ }
+ /**
+ * Get the actual user-facing value (for validation and submission)
+ */
+ getFieldCheckedValue(element) {
+ // Handle checkboxes and radios based on checked state
+ if (element.type === 'checkbox') {
+ const type = this.getFieldType(element);
+ if (type === 'true-false') {
+ return element.checked;
+ }
+ return element.checked ? element.value : '';
+ }
- case 'phone':
- return `<a href="tel:${value.replace(/\D/g, '')}">${value}</a>`;
+ if (element.type === 'radio') {
+ const radioGroup = document.querySelectorAll(`input[name="${element.name}"]`);
+ const checked = Array.from(radioGroup).find(r => r.checked);
+ return checked ? checked.value : '';
+ }
+
+ // For everything else, use existing logic
+ return this.getFieldValue(element);
+ }
+
+ isEmptyValue(value) {
+ if (value === null || value === undefined || value === '') return true;
+ if (Array.isArray(value) && value.length === 0) return true;
+ return typeof value === 'object' && Object.keys(value).length === 0;
+ }
+ getRepeaterValue(element, conf) {
+ if (!conf.container) {
+ conf.container = conf.field?.querySelector('.repeater-items');
+ this.saveItem(conf);
+ }
+ let value = [];
+ Array.from(conf.container.children).forEach(row => {
+ let rowData = {};
+ row.querySelectorAll('[data-field]').forEach(field => {
+ rowData[field.dataset.field] = this.getFieldValue(field);
+ });
+ value.push(rowData);
+ });
+ return value;
+ }
+ getTagListValue(element, conf) {
+ if (!conf.container) {
+ conf.container = conf.field?.querySelector('.tag-items');
+ this.saveItem(conf);
+ }
+ let value = [];
+ Array.from(conf.container.children).forEach(item => {
+ let inputs = item.querySelectorAll('input[type="hidden"]');
+ let fieldData = {};
+ inputs.forEach(input => {
+ fieldData[input.dataset.field] = input.value;
+ });
+ value.push(fieldData);
+ });
+ return value;
+ }
+ getLocationValue(element, conf) {
+ if(!conf.values){
+ conf.values = Array.from(conf.field?.querySelectorAll('[data-location-field]'));
+ this.saveItem(conf);
+ }
+ let value = {};
+ conf.values.forEach(input => {
+ value[input.dataset.locationField] = input.value;
+ });
+ return value;
+ }
+ getHiddenInputValue(element, conf, fieldName) {
+ if (!conf.value) {
+ conf.value = conf.field?.querySelector(`input[type=hidden][name="${fieldName}"]`);
+ this.saveItem(conf);
+ }
+ return conf.value.value;
+ }
+
+ /**
+ * Format field value for display in summary
+ * @param {*} value - The field value
+ * @param {Object} input - The input config
+ * @returns {HTMLElement|string} - Formatted display element or string
+ */
+ formatValueForSummary(value, input) {
+ const fieldType = this.getFieldType(input.element);
+
+ // Handle empty values
+ if (this.isEmptyValue(value)) {
+ return '';
+ }
+
+ // Handle different field types
+ switch (fieldType) {
+ case 'repeater':
+ return this.formatRepeaterForSummary(value, input);
+
+ case 'tag-list':
+ return this.formatTagListForSummary(value, input);
+
+ case 'location':
+ return this.formatLocationForSummary(value);
+
+ case 'true-false':
+ return value ? 'Yes' : 'No';
+
+ case 'checkbox':
+ // Handle multi-checkbox arrays
+ if (Array.isArray(value)) {
+ return this.formatCheckboxGroupForSummary(value, input);
+ }
+ // Single checkbox - get display label
+ return this.getDisplayLabel(input, value);
+
+ case 'selector':
+ case 'upload':
+ // These might need special handling depending on your needs
+ return this.formatHiddenFieldForSummary(value, input, fieldType);
default:
- // Handle arrays (multi-select, checkbox group)
- if (Array.isArray(value)) {
- return this.formatArrayValue(value);
+ // For radio/checkbox, get the display label
+ if (typeof value === 'string') {
+ return this.getDisplayLabel(input, value);
+ }
+ // For textarea or any multi-line text, convert line breaks
+ if (typeof value === 'string' && value.includes('\n')) {
+ return this.convertLineBreaks(value);
}
return value;
}
}
/**
- * Format repeater field value
+ * Format checkbox group values with labels
*/
- formatRepeaterValue(rows) {
- if (!Array.isArray(rows) || rows.length === 0) {
- return '<em>No entries</em>';
- }
+ formatCheckboxGroupForSummary(values, input) {
+ const labels = values.map(value => this.getDisplayLabel(input, value));
+ return labels.join(', ');
+ }
- let html = '<div class="repeater-summary">';
+ /**
+ * Convert \n line breaks to HTML
+ */
+ convertLineBreaks(text) {
+ const container = document.createElement('span');
+ container.innerHTML = text.split('\n').join('<br>');
+ return container;
+ }
+
+ /**
+ * Format repeater data as a list
+ */
+ formatRepeaterForSummary(rows, input) {
+ const container = document.createElement('div');
+ container.className = 'summary-repeater';
+
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>`;
- }
+ const rowDiv = document.createElement('div');
+ rowDiv.className = 'summary-repeater-row';
+
+ const rowTitle = document.createElement('strong');
+ rowTitle.textContent = `Entry ${index + 1}:`;
+ rowDiv.appendChild(rowTitle);
+
+ const fieldsList = document.createElement('ul');
+ fieldsList.className = 'summary-repeater-fields';
+
+ for (const [fieldName, fieldValue] of Object.entries(row)) {
+ if (this.isEmptyValue(fieldValue)) continue;
+
+ const li = document.createElement('li');
+
+ // Try to find the label for this subfield
+ const subFieldElement = input.field?.querySelector(`[data-field="${fieldName}"]`);
+ const label = subFieldElement?.closest('.field')?.querySelector('label')?.textContent.replace('*', '').trim() || fieldName;
+
+ li.innerHTML = `<span class="field-label">${label}:</span> <span class="field-value">${fieldValue}</span>`;
+ fieldsList.appendChild(li);
}
- html += `</ul></div>`;
+
+ rowDiv.appendChild(fieldsList);
+ container.appendChild(rowDiv);
});
- html += '</div>';
- return html;
+
+ return container;
}
/**
- * Format group field value
+ * Format tag-list data
*/
- formatGroupValue(groupData) {
- if (typeof groupData !== 'object' || Object.keys(groupData).length === 0) {
- return '<em>No data</em>';
- }
+ formatTagListForSummary(tags, input) {
+ const container = document.createElement('div');
+ container.className = 'summary-taglist';
- 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>`;
- }
+ const tagsList = document.createElement('ul');
+ tagsList.className = 'summary-tags';
+
+ tags.forEach(tag => {
+ const li = document.createElement('li');
+ li.className = 'summary-tag';
+
+ // Get the primary display value (first non-empty field)
+ const displayValue = Object.values(tag).find(v => !this.isEmptyValue(v)) || '';
+
+ // If there are multiple fields, show them all
+ const fields = Object.entries(tag).filter(([k, v]) => !this.isEmptyValue(v));
+ if (fields.length > 1) {
+ li.textContent = fields.map(([k, v]) => v).join(', ');
+ } else {
+ li.textContent = displayValue;
}
- }
- html += '</ul></div>';
- return html;
+
+ tagsList.appendChild(li);
+ });
+
+ container.appendChild(tagsList);
+ return container;
}
/**
- * Format location field value
+ * Format location data
*/
- formatLocationValue(location) {
- if (typeof location !== 'object') return location;
-
+ formatLocationForSummary(location) {
const parts = [];
- const fields = ['address', 'city', 'state', 'zip', 'country'];
- fields.forEach(field => {
- if (location[field]) {
- parts.push(location[field]);
- }
- });
+ if (location.street) parts.push(location.street);
+ if (location.city) parts.push(location.city);
+ if (location.province) parts.push(location.province);
+ if (location.postal_code) parts.push(location.postal_code);
+ if (location.country) parts.push(location.country);
- return parts.join(', ');
+ return parts.length > 0 ? parts.join(', ') : location.address || '';
}
/**
- * Format file/image value
+ * Format hidden field types (upload, selector)
*/
- 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>`;
+ formatHiddenFieldForSummary(value, input, fieldType) {
+ if (fieldType === 'upload') {
+ // Get upload preview images if available
+ const uploadField = input.field?.querySelector('[data-upload-field]');
+ if (uploadField) {
+ const previews = uploadField.querySelectorAll('.item-grid.preview img');
+ if (previews.length > 0) {
+ const container = document.createElement('div');
+ container.className = 'summary-uploads';
+ previews.forEach(img => {
+ const clone = img.cloneNode(true);
+ clone.style.maxWidth = '100px';
+ clone.style.maxHeight = '100px';
+ container.appendChild(clone);
+ });
+ return container;
+ }
}
+ return `${value.split(',').length} file(s) uploaded`;
+ }
+
+ if (fieldType === 'selector') {
+ // Could enhance this to show selected item names if available
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
+ * Get the display label for an input value (especially for radio/checkbox)
+ * @param {Object} input - The input config from this.inputs
+ * @param {*} value - The field value
+ * @returns {string} - The display label or original value
*/
- formatTextareaValue(value, type) {
- if (!value) return '<em>Empty</em>';
+ getDisplayLabel(input, value) {
+ if (!input.element) return value;
- // 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;
+ const inputType = input.element.type;
+
+ // Handle radio buttons
+ if (inputType === 'radio') {
+ const radioGroup = input.field.querySelectorAll(`input[type="radio"][name="${input.element.name}"]`);
+ const selectedRadio = Array.from(radioGroup).find(radio => radio.value === value);
+ if (selectedRadio) {
+ const label = selectedRadio.closest('label') ||
+ input.field.querySelector(`label[for="${selectedRadio.id}"]`);
+ if (label) {
+ return label.textContent.replace('*', '').trim();
+ }
+ }
}
- // 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('');
+ // Handle checkboxes (including groups)
+ if (inputType === 'checkbox' && this.getFieldType(input.element) !== 'true-false') {
+ // Find checkbox with this value in the field
+ const checkbox = input.field.querySelector(`input[type="checkbox"][value="${value}"]`);
+ if (checkbox) {
+ const label = checkbox.closest('label') ||
+ input.field.querySelector(`label[for="${checkbox.id}"]`);
+ if (label) {
+ // Get just the span content to avoid getting nested elements
+ const span = label.querySelector('span');
+ return span ? span.textContent.trim() : label.textContent.replace('*', '').trim();
+ }
+ }
}
- // Single paragraph - just convert newlines to breaks
- return text.replace(/\n/g, '<br>');
+ return value;
}
+ getItem(element, formId = null) {
+ const hasID = Object.hasOwn(element.dataset, 'ref');
+ let id = (hasID) ? element.dataset.ref : window.generateID('input');
+ if (!hasID) element.dataset.ref = id;
- /**
- * Convert newlines to <br> tags (kept for backwards compatibility)
- */
- nl2br(text) {
- return this.formatPlainText(text);
+ //check if we have it already
+ if (!this.inputs.has(id)) {
+ if (!formId) {
+ formId = element.closest('[data-form-id]')?.dataset.formId??false;
+ }
+ let field = this.getField(element);
+
+ this.inputs.set(id, {
+ id: id,
+ element: element,
+ form: formId,
+ field: field,
+ section: element.closest('[data-tab]')?.dataset.tab ?? false,
+ ui: window.uiFromSelectors(this.selectors.fields, field)
+ });
+ }
+
+ return this.inputs.get(id);
}
-
- /**
- * Event system
- */
+ saveItem(config) {
+ this.inputs.set(config.id, config);
+ }
+ /**********************************************************************
+ Subscription
+ **********************************************************************/
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);
+ this.subscribers.forEach(cb => {
+ try {
+ cb(event, data);
+ } catch (e) {
+ console.error('HandleSelection subscriber error:', e);
}
});
-
- // Clear maps
- this.specialFields.clear();
- this.forms.clear();
- this.activeRepeaters.clear();
-
- if (this.forms) {
+ }
+ /**********************************************************************
+ Cleanup
+ **********************************************************************/
+ destroy() {
+ if (this.forms.size > 0) {
+ Array.from(this.forms.values()).forEach(form => {
+ this.removeFormListeners(form);
+ });
this.forms.clear();
}
+ if (this.repeaters.size > 0) {
+ Array.from(this.repeaters.values()).forEach(repeater => {
+ this.removeRepeaterListeners(repeater.element);
+ repeater.sortable?.destroy();
+ });
+ this.repeaters.clear();
+ }
+ if (this.quantityFields.size > 0) {
+ Array.from(this.quantityFields.values()).forEach(num => {
+ this.removeQuantityListeners(num.element);
+ });
+ this.quantityFields.clear();
+ }
+ if (this.tagLists.size > 0) {
+ Array.from(this.tagLists.values()).forEach(tagList => {
+ this.removeTagListListeners(tagList.element);
+ });
+ this.tagLists.clear();
+ }
+ if(this.charLimits.size > 0) {
+ Array.from(this.charLimits.values()).forEach(charLimit => {
+ charLimit.removeEventListener('input', this.countUpdaters);
+ })
+ }
+ this.inputs.clear();
+ this.forms.clear();
+ this.charLimits.clear();
+
}
}
-
-document.addEventListener('DOMContentLoaded', () => {
- window.jvbForm = FormController;
- console.log('FormController in window');
+document.addEventListener('DOMContentLoaded', async function () {
+ window.auth.subscribe(event => {
+ if (event === 'auth-loaded') {
+ window.jvbForm = new FormController();
+ }
+ });
});
--
Gitblit v1.10.0