From a9b3b28d001941921aa70d37fdc87c758a163a44 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 05 Jun 2026 16:47:03 +0000
Subject: [PATCH] =Some hefty changes to FeedBlock. Transitioning to loading first page in php to save on extra requests. Got a bit to do yet, but I have to work on Northeh for a bit here.
---
assets/js/concise/FormController.js | 3803 ++++++++++++++++++++++++++--------------------------------
1 files changed, 1,723 insertions(+), 2,080 deletions(-)
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 8e5fbdc..3ba05b4 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -1,885 +1,1193 @@
-/**
- * Enhanced FormController - Manages forms with special fields, caching, and queue integration
- * Works with DataStore for CRUD operations and standalone for front-end forms
- */
class FormController {
- constructor(config = {}) {
- this.config = {
- collectFormData: false,
- ... config
- }
+ constructor() {
+ this.a11y = window.jvbA11y;
+ this.error = window.jvbError;
+ this.queue = window.jvbQueue;
+ this.populate = window.jvbPopulate;
+
+ this.changes = new Map();
+ this.forms = 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();
+
+ this.subscribers = new Set();
+
this.isRestoring = false;
+ this.hasListeners = false;
+ this.hasUploads = false;
+ this.summaryTemplate = false;
+
+ this.init();
+ }
+ init() {
+ this.templates = window.jvbTemplates;
+ this.defineSummaryTemplate();
+ this.initElements();
+ this.initListeners();
+ this.initStore();
+ this.initValidators();
+ this.initUploadSubscription();
+ }
+
+ initUploadSubscription() {
+ window.jvbUploads.subscribe((event, data) => {
+ if (!this.hasUploads) return;
+ if (event === 'upload-received') {
+ let form = this.getForm(data.field);
+ if (form) {
+ this.updateItem(`${data.field.dataset.field}_tempUpload`, data.id, form);
+ }
+
+ }
+ });
+ }
+ 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',
+ },
+ dependsOn: '[data-depends-on]',
+ forms: {
+ status: {
+ status: '.fstatus',
+ message: '.fstatus .message',
+ icon: '.fstatus .icon',
+ actions: '.fstatus .actions',
+ },
+ restore: {
+ container: '.restore-form',
+ restore: '[data-action="restore"]',
+ clear: '[data-action="clear"]',
+ }
+ },
+ inputs: this.inputSelectors, //querySelectorAll
+ fields: {
+ field: '.field', //querySelectorAll
+ label: 'label',
+ success: '.success',
+ error: '.error',
+ 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',
+ item: '.tag-item',
+ 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-maxlength]',
+ limit: '.limit',
+ current: '.current',
+ }
+ };
+ }
+ 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: 'formId',
+ keyPath: 'id',
indexes: [
- { name: 'status', keyPath: 'status' },
- { name: 'operationId', keyPath: 'operationId' },
+ { name: 'src', keyPath: 'src'},
{ name: 'timestamp', keyPath: 'timestamp' },
{ name: 'formType', keyPath: 'type' }
],
TTL: 7 * 24 * 60 * 1000, //7 days
- validateData: true,
- delayFetch: true
});
this.store = store.forms;
- this.debouncer = window.debouncer;
+ this.store.subscribe((event, data)=> {
+ if (event === 'data-ready') {
+ let stored = this.store.getFiltered();
- this.ignore = [];
-
- this.populateForm = window.jvbPopulate;
-
- this.subscribers = new Set();
- this.forms = new Map();
- this.specialFields = new Map();
- this.dependencies = new Map();
-
- // Validation (YOU ARE GREAT!)
- this.validators = this.initValidators();
- this.touchedFields = new Set();
-
- // Auto-save configuration
- this.autoSaveDefaults = {
- delay: 3000, // 3 seconds
- typingDelay: 1500, // 1.5 seconds for text fields
- enabled: true
- };
-
- // Repeater field management
- this.activeRepeaters = new Map();
- this.repeaterDelays = {
- change: 6000,
- typing: 3000,
- blur: 1500,
- add: 500,
- remove: 800,
- reorder: 1000
- };
-
- this.isTimeline = window.crudManager && window.crudManager.isTimeline;
-
- // Bind handlers
- this.clickHandler = this.handleClick.bind(this);
- this.changeHandler = this.handleChange.bind(this);
- this.submitHandler = this.handleSubmit.bind(this);
- this.inputHandler = this.handleInput.bind(this);
- this.blurHandler = this.handleBlur.bind(this);
- //Processors
- this.processRepeaterField = this.processRepeaterField.bind(this);
- this.processGroupField = this.processGroupField.bind(this);
- this.processLocationField = this.processLocationField.bind(this);
- this.processRegularField = this.processRegularField.bind(this);
-
- this.init();
- }
-
- async init() {
- this.store.subscribe(this.handleStoreEvent.bind(this));
-
- // Set up global form handlers for standalone forms
- this.initListeners();
- if (window.jvbQueue) {
- window.jvbQueue.subscribe((event, data) => {
- if (event === 'operation-completed' && data.type === 'form') {
- this.handleOperationComplete(data);
+ let pending = stored.filter(form=> form.src === window.location.pathname);
+ for (let form of pending) {
+ this.showPendingNotification(form.id, form.changes);
}
- });
- }
- }
-
- /**
- * Handle operation completion - clear related form cache
- */
- async handleOperationComplete(operation) {
- // Clear the form data from store
- if (operation.formId) {
- try {
- await this.store.delete(operation.formId);
- } catch (error) {
- console.warn('Failed to clear form cache:', error);
+ } else if (event === 'operation-status' && data.status === 'completed') {
+ if (data.config) {
+ this.store.delete(data.config.id);
+ }
}
- }
-
- // Clear any related form state
- const form = this.forms.get(operation.formId);
- if (form) {
- form.isDirty = false;
- form.lastSaved = Date.now();
- form.data = {};
- }
- }
-
- handleStoreEvent(event, data) {
- switch(event) {
- case 'item-saved':
- if (data.item.status === 'autosave') {
- // this.showFormStatus(data.item.formId, 'autosave');
- }
- break;
- case 'data-loaded':
- this.checkPendingForms();
- break;
- }
- }
-
- /**
- * Check for pending forms from current page
- */
- async checkPendingForms() {
- const allForms = await this.store.getAll();
- const currentPath = window.location.pathname;
-
- const pendingForms = allForms.filter(form => {
- if (form.status !== 'draft') return false;
-
- // Check if form is from current page
- const formPath = form.data?._wp_http_referer;
- return formPath === currentPath;
});
+ }
+ showPendingNotification(formId, changes) {
+ let form = this.forms.get(formId);
+ if (!form) return;
+ let element = form.element;
+ if (!element) {
+ console.warn(`Form element not found for: ${formId}`);
+ return;
+ }
- pendingForms.forEach(item => {
- const formElement = this.findFormElement(item);
- if (!formElement) return;
-
- // Register form if not already registered
- let formConfig = this.forms.get(item.formId);
- if (!formElement.dataset.formId) {
- formConfig = this.registerForm(formElement);
- }
-
- // Set flag to prevent event handlers from firing
+ form.ui.restore.container.hidden = false;
+ const handleRestore = async (changes, element) => {
this.isRestoring = true;
- // Auto-populate the form
- new this.populateForm(formElement, item.data);
+ let theChanges = {['fields']: changes};
+ await this.checkStoredUploads(changes, element);
+ this.populate.populate(element, theChanges);
+ this.a11y.announce('Previous changes restored');
+ this.isRestoring = false;
+ form.ui.restore.container.remove();
+ };
+ const clearRestore = async (formId) => {
+ await this.checkStoredUploads(changes, element, false);
+ await this.store.delete(formId);
+ this.a11y.announce('Previous changes discarded');
+ form.ui.restore.container.remove();
+ };
+ form.ui.restore.restore.addEventListener('click', () => handleRestore(changes, element));
+ form.ui.restore.clear.addEventListener('click', async () => clearRestore(formId));
+ }
+ async checkStoredUploads(changes, element, restore = true) {
+ let form = this.forms.get(element.dataset.formId);
+ if (!form) return;
+ let uploads = [];
+ for (let [key, value] of Object.entries(changes)) {
+ if (key.includes('_tempUpload')) {
+ let field = key.replace('_tempUpload', '');
- // Reset flag after a tick (gives DOM time to settle)
- setTimeout(() => {
- this.isRestoring = false;
- }, 0);
-
- // Show restore status
- this.showFormStatus(item.formId, 'restored');
-
- if (window.jvbA11y) {
- window.jvbA11y.announce('Your previous entry has been restored');
+ if (Object.hasOwn(form.ui.uploads, field)) {
+ uploads = [
+ ... uploads,
+ ... value
+ ];
+ }
+ }
}
- });
- }
- /**
- * Find form element that matches the cached data
- */
- findFormElement(formData) {
- // Try by form_id first (hidden field)
- if (formData.data?.form_id) {
- const form = document.querySelector(`[name="form_id"][value="${formData.data.form_id}"]`)?.closest('form');
- if (form) return form;
+ if (uploads.length > 0) {
+ if (restore) {
+ await window.jvbUploads.restoreUploads(uploads);
+ } else {
+ await window.jvbUploads.clearUploads(uploads);
+ }
+
+ }
+ }
+ initValidators() {
+ 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 https://'
+ },
+ phone: {
+ pattern: /^[\d\s\-+().]+$/,
+ message: 'Please enter a valid phone number'
+ },
+ number: {
+ test: (value, fieldWrapper) => {
+ const num = parseFloat(value);
+ if (isNaN(num)) return 'Please enter a valid number';
+
+ const min = fieldWrapper.dataset.min;
+ const max = fieldWrapper.dataset.max;
+
+ if (min !== undefined && num < parseFloat(min)) {
+ return `Value must be at least ${min}`;
+ }
+ if (max !== undefined && num > parseFloat(max)) {
+ return `Value must be at most ${max}`;
+ }
+ return true;
+ }
+ },
+ text: {
+ test: (value, fieldWrapper) => {
+ const minLength = fieldWrapper.dataset.minlength;
+ const maxLength = fieldWrapper.dataset.maxlength;
+
+ if (minLength && value.length < parseInt(minLength)) {
+ return `Must be at least ${minLength} characters`;
+ }
+ if (maxLength && value.length > parseInt(maxLength)) {
+ return `Must be no more than ${maxLength} characters`;
+ }
+ return true;
+ }
+ }
+ };
+ }
+ 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);
+
+ if (!value && !input.required) {
+ return { isValid: true, message: '' };
}
- // Try by form_type
- if (formData.data?.form_type) {
- const form = document.querySelector(`[name="form_type"][value="${formData.data.form_type}"]`)?.closest('form');
- if (form) return form;
+ 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' };
+ }
}
- // Fallback: try by formId (if it was already registered)
- return document.querySelector(`[data-form-id="${formData.formId}"]`);
+ if(input.checkValidity && !input.checkValidity()){
+ return {isValid: false, message: input.validationMessage};
+ }
+
+ if (value && Object.hasOwn(field.dataset, 'pattern')) {
+ const regex = new RegExp(field.dataset.pattern);
+ if (!regex.test(value)) {
+ return {isValid: false, message: field.dataset.validationMessage || 'Invalid format'};
+ }
+ }
+
+ if (Object.hasOwn(field.dataset, 'validate') || input.type) {
+ const validator = this.validators[field.dataset.validate||input.type];
+
+ if (validator && validator.pattern && !validator.pattern.test(value)) {
+ return {isValid: false, message: validator.message};
+ }
+
+ if (validator && validator.test) {
+ const result = validator.test(value, field);
+ if (result !== true) {
+ return {isValid: false, message: result};
+ }
+ }
+ }
+
+ return {isValid: true, message: ''};
+ }
+ updateValidationUI(input, result) {
+ if (result.isValid) {
+ this.showSuccess(input, result.message);
+ } else {
+ this.showError(input, result.message);
+ }
}
- /**
- * Show notification for pending changes
- */
- /**
- * Show notification for pending changes
- */
- showPendingNotification(formId, formData) {
- const formElement = document.querySelector(`[data-form-id="${formId}"]`);
- if (!formElement) return;
-
- const notification = document.createElement('div');
- notification.className = 'pending-changes-notification';
- notification.innerHTML = `
- <p>We noticed unsaved changes from last time. Would you like to restore them?</p>
- <button class="restore-changes" data-form-id="${formId}">Restore</button>
- <button class="discard-changes" data-form-id="${formId}">Discard</button>
- `;
-
- formElement.insertBefore(notification, formElement.firstChild);
-
- // Add handlers
- notification.querySelector('.restore-changes').addEventListener('click', async () => {
- await this.restorePendingForm(formId, formData);
- notification.remove();
- });
-
- notification.querySelector('.discard-changes').addEventListener('click', async () => {
- await this.discardPendingForm(formId);
- notification.remove();
- });
- }
-
- /**
- * Restore pending form data
- */
- async restorePendingForm(formId, formData) {
- const form = document.querySelector(`[data-form-id="${formId}"]`);
+ handleClick(e) {
+ let form = this.getForm(e.target);
if (!form) return;
- // Populate form with cached data
- new this.populateForm(form, formData);
-
- // Update status in store (mark as restored, not draft)
- await this.store.save({
- formId: formId,
- data: formData,
- status: 'restored',
- timestamp: Date.now()
- });
-
- if (window.jvbA11y) {
- window.jvbA11y.announce('Previous changes restored');
- }
- }
-
- /**
- * Discard pending form data
- */
- async discardPendingForm(formId) {
- try {
- await this.store.delete(formId);
-
- if (window.jvbA11y) {
- window.jvbA11y.announce('Previous changes discarded');
+ 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;
}
- } catch (error) {
- console.error('Failed to discard pending form:', error);
+ }
+ }
+
+ handleChange(e) {
+ if (e.target.closest('[data-ignore]') || this.isRestoring) return;
+
+ let field = this.getField(e.target);
+
+ // Check if this input lives inside a collection field
+ const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]');
+ if (collectionField) {
+ // Dependencies still need checking
+ if (this.dependencies.has(field.dataset.field)) {
+ let dependency = this.dependencies.get(field.dataset.field);
+ dependency.forEach(item => {
+ this.checkFieldDependency(item, field.dataset.field);
+ });
+ }
+ const collectionName = collectionField.dataset.field;
+ window.debouncer.schedule(
+ `collection:${collectionName}`,
+ () => this.updateCollectionField(collectionField),
+ 150
+ );
+ return;
+ }
+
+ //Dependencies
+ if (this.dependencies.has(field.dataset.field)) {
+ let dependency = this.dependencies.get(field.dataset.field);
+ dependency.forEach(item => {
+ this.checkFieldDependency(item, field.dataset.field);
+ });
+ }
+
+ let form = this.getForm(e.target);
+ this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
+ }
+
+ handleBlur(e) {
+ if (e.target.closest('[data-ignore]') || this.isRestoring) return;
+ let form = this.getForm(e.target);
+ if (!form) return;
+
+ let field = this.getField(e.target);
+ let fieldName = field.dataset.field;
+ window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
+ this.validateField(e.target);
+
+ // If inside a collection, update the whole collection instead
+ const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]');
+ if (collectionField) {
+ this.updateCollectionField(collectionField);
+ return;
+ }
+
+ this.updateItem(fieldName, this.getFieldValue(e.target), form);
+ }
+
+ handleInput(e){
+ if (e.target.closest('[data-ignore]') || this.isRestoring) return;
+ let form = this.getForm(e.target);
+ if (!form) return;
+
+ let field = this.getField(e.target);
+ if (!field) return;
+
+ const input = e.target; // Capture reference
+ const fieldName = field.dataset.field;
+
+ // Show pending status regardless of cache
+ this.showFormStatus(form.id, 'pending');
+
+ // 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});
}
}
/**
- * Setup global handlers for standalone forms
+ * Updates the item, schedules caching if
+ * @param name
+ * @param value
+ * @param form
*/
- initListeners() {
- // Only add if not already added
- if (!this.globalHandlersAdded) {
- document.addEventListener('click', this.clickHandler);
- document.addEventListener('change', this.changeHandler);
- document.addEventListener('blur', this.blurHandler, true);
- document.addEventListener('input', this.inputHandler);
- this.globalHandlersAdded = true;
+ updateItem(name, value, form) {
+ if (value === undefined) return;
+ 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);
+ //If it is temporary uploads, we need to store them all
+ if (name.includes('_tempUpload')) {
+ if (!Object.hasOwn(changes.changes, name)) {
+ changes.changes[name] = [];
+ }
+ changes.changes[name].push(value);
+ } else {
+ 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 standalone form (for front-end forms)
+ * Register a form for handling
+ * @param {HTMLElement} form
+ * @param {object} options
*/
- registerForm(formElement, options = {}) {
- if (!formElement) return;
- const formId = formElement.dataset.formId || `form_${Date.now()}`;
- formElement.dataset.formId = formId;
+ registerForm(form, options) {
+ options = {
+ autoUpload: false,
+ imageMeta: true,
+ delay: 1500,
+ endpoint: Object.hasOwn(form.dataset, 'save') ? form.dataset.save: '',
+ showStatus: true,
+ showSummary: false,
+ cache: true,
+ ignore: [],
+ ... options
+ };
+ //Bail if form already registered
+ if (Object.hasOwn(form.dataset, 'formId') && this.forms.has(form.dataset.formId)) return;
- formElement.addEventListener('submit', this.submitHandler);
+ if (!Object.hasOwn(form.dataset, 'formId')) {
+ form.dataset.formId = window.generateID('form_');
+ }
+ const formId = form.dataset.formId;
- const formConfig = {
- element: formElement,
+ this.addFormListeners(form);
+
+ const config = {
+ element: form,
id: formId,
status: '',
- options: {
- autosave: 'autosave' in formElement.dataset,
- autoUpload: true,
- saveDelay: this.autoSaveDefaults.delay,
- endpoint: formElement.dataset.save ?? '',
- formStatus: true,
- cache: true,
- ...options
- },
- dependencies: new Map(),
- data: this.collectFormData(formElement, true),
+ options: options,
+ ui: window.uiFromSelectors(this.selectors.forms, form)
};
- this.initializeFormFields(formElement, formConfig);
- this.forms.set(formId, formConfig);
+ config.ui.fields = {};
+ form.querySelectorAll('[data-field]').forEach((field) => {
+ config.ui.fields[field.dataset.field] = field;
+ });
- // Check for pending data - FIXED
- if (this.store && formConfig.options.cache) {
- const cached = this.store.get(formId);
- if (cached && cached.data) {
- this.showPendingNotification(formId, cached.data);
- }
- }
+ this.initializeFields(form, config);
+ this.forms.set(formId, config);
- return formConfig;
+ return config;
}
+ clearForm(formId) {
+ const config = this.forms.get(formId);
+ if (!config) return;
- /**
- * Initialize all special fields in a form
- */
- initializeFormFields(form, formConfig = null) {
- // Initialize Quill editors
- this.initQuillEditors(form);
-
- // Initialize repeater fields
- this.initRepeaterFields(form, formConfig);
-
- this.initTagListFields(form, formConfig);
-
- // Initialize conditional fields
- if (formConfig) {
- this.initConditionalFields(form, formConfig);
+ if (config.unsubscribeTabs) {
+ config.unsubscribeTabs();
+ }
+ if(config.tabs) {
+ window.jvbTabs.removeTab(config.element);
}
- // Initialize character limits
- this.initCharacterLimits(form);
+ if (config.cache && this.changes.has(formId)) this.saveCache(formId);
- // Initialize image upload fields
- this.initImageUploadFields(form, formConfig);
-
- // Initialize tabs if present
- if (window.jvbTabs && form.querySelector('nav.tabs')) {
- formConfig.tabs = new window.jvbTabs(form);
- this.forms.set(formConfig.formId, formConfig);
- this.initSteppedForm(formConfig.formId);
- }
-
- // Scan for existing selector fields
- if (window.jvbSelector) {
- window.jvbSelector.scanExistingFields(form);
- }
- }
-
- /**
- * Initialize stepped form functionality
- */
- initSteppedForm(formId) {
- const formConfig = this.forms.get(formId);
- const form = formConfig.element;
- const tabsInstance = formConfig.tabs;
-
- const sections = form.querySelectorAll('.tab-content');
- const totalSteps = sections.length;
- const progressBar = form.querySelector('.form-progress .fill');
- const stepText = form.querySelector('.step-text .current');
- const tabButtons = form.querySelectorAll('nav.tabs button');
-
- // Update progress display
- const updateProgress = (currentStep) => {
- const progress = (currentStep / totalSteps) * 100;
- if (progressBar) {
- progressBar.style.width = progress + '%';
+ // Cleanup items
+ for (let [id, input] of this.inputs.entries()) {
+ if (input.form === formId) {
+ this.inputs.delete(id);
}
- if (stepText) {
- stepText.textContent = currentStep;
+ }
+ // Clean up dependencies for this form
+ this.dependencies.forEach((dependency, fieldName) => {
+ dependency = dependency.filter(item => item.form !== formId);
+
+ // Remove the dependency entry entirely if no items left
+ if (dependency.length === 0) {
+ this.dependencies.delete(fieldName);
}
+ });
- // Update tab states
- tabButtons.forEach((btn, idx) => {
- const stepNum = idx + 1;
- btn.classList.remove('current', 'completed', 'pending');
+ if (Object.hasOwn(config, 'hasQuill') && this.quillInstances.has(formId)) {
+ const instances = this.quillInstances.get(formId);
+ instances.forEach(quillInstance => {
+ // Disable the editor
+ quillInstance.disable();
- if (stepNum < currentStep) {
- btn.classList.add('completed');
- } else if (stepNum === currentStep) {
- btn.classList.add('current');
- } else {
- btn.classList.add('pending');
+ // 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;
+ }
- // Next/Previous button handling
- form.addEventListener('click', (e) => {
- const nextBtn = e.target.closest('[data-action="next-step"]');
- const prevBtn = e.target.closest('[data-action="prev-step"]');
-
- if (nextBtn) {
- e.preventDefault();
- const currentSection = nextBtn.closest('.tab-content');
- const currentStep = parseInt(currentSection.dataset.step);
- const nextSection = form.querySelector(`.tab-content[data-step="${currentStep + 1}"]`);
-
- if (nextSection && this.validateStep(currentSection)) {
- const nextTab = nextSection.dataset.tab;
- tabsInstance.switchTab(nextTab, true);
- updateProgress(currentStep + 1);
-
- // Scroll to top of form
- form.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- }
-
- if (prevBtn) {
- e.preventDefault();
- const currentSection = prevBtn.closest('.tab-content');
- const currentStep = parseInt(currentSection.dataset.step);
- const prevSection = form.querySelector(`.tab-content[data-step="${currentStep - 1}"]`);
-
- if (prevSection) {
- const prevTab = prevSection.dataset.tab;
- tabsInstance.switchTab(prevTab, true);
- updateProgress(currentStep - 1);
-
- // Scroll to top of form
- form.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- }
- });
-
- // Update progress when tabs are clicked directly
- const originalSwitchTab = tabsInstance.switchTab.bind(tabsInstance);
- tabsInstance.switchTab = (tab, updateHistory) => {
- originalSwitchTab(tab, updateHistory);
- const activeSection = form.querySelector(`.tab-content[data-tab="${tab}"]`);
- if (activeSection) {
- const step = parseInt(activeSection.dataset.step);
- updateProgress(step);
- }
- };
-
- // Initialize progress
- updateProgress(1);
- }
-
- /**
- * Validate current step before allowing progression
- * Can be enhanced with custom validation rules
- */
- validateStep(section) {
- const fields = section.querySelectorAll('.field');
- let allValid = true;
-
- fields.forEach(fieldWrapper => {
- const input = fieldWrapper.querySelector('input, textarea, select');
- if (input && !input.closest('[hidden]')) {
- const isValid = this.validateField(input, fieldWrapper);
- if (!isValid) {
- allValid = false;
- }
- }
- });
-
- return allValid;
- }
-
-
- /**
- * Initialize Quill editors
- */
- initQuillEditors(form) {
- window.jvbQuill(form);
- }
-
- /**
- * Initialize repeater fields
- */
- initRepeaterFields(form, formConfig) {
- form.querySelectorAll('.repeater').forEach(repeater => {
- const addButton = repeater.querySelector('.add-repeater-row');
- const container = repeater.querySelector('.repeater-items');
- const template = repeater.querySelector('template');
-
- if (!addButton || !template || !container) return;
-
- // Initialize Sortable for drag-and-drop
- if (window.Sortable) {
- new Sortable(container, {
- handle: '.repeater-row-header',
- animation: 150,
- onEnd: () => {
- this.updateRepeaterOrder(repeater, formConfig);
+ if (check.has(item.id)) {
+ check.delete(item.id);
}
});
}
-
- // Add row handler
- addButton.addEventListener('click', () => {
- this.addRepeaterRow(repeater, formConfig);
- });
-
- // Remove row handlers
- container.addEventListener('click', (e) => {
- if (e.target.closest('.remove-row')) {
- this.removeRepeaterRow(e.target.closest('.repeater-row'), formConfig);
- }
- });
- });
- }
-
- /**
- * Add repeater row
- */
- addRepeaterRow(repeater, formConfig) {
- const container = repeater.querySelector('.repeater-items');
- const template = repeater.querySelector('template');
- const index = container.children.length;
- const fieldName = repeater.dataset.field;
-
- // Clone template
- const row = template.content.cloneNode(true).firstElementChild;
- row.dataset.index = index;
-
- // Update field names
- row.querySelectorAll('input, select, textarea').forEach(field => {
- const originalName = field.name;
- field.name = `${fieldName}:${index}:${originalName}`;
- field.id = `${fieldName}-${index}-${originalName}`;
-
- // Update label if exists
- const label = field.nextElementSibling;
- if (label && label.tagName === 'LABEL') {
- label.htmlFor = field.id;
- }
- });
-
- container.appendChild(row);
-
- if (formConfig) {
- this.scheduleSave(formConfig, {
- type: 'repeater',
- action: 'add',
- fieldName: fieldName,
- delay: this.repeaterDelays.add
- });
}
- if (window.jvbA11y) {
- window.jvbA11y.announce('Row added');
- }
+
+ 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??[]];
- /**
- * Remove repeater row
- */
- removeRepeaterRow(row, formConfig) {
- const repeater = row.closest('.repeater');
- const fieldName = repeater.dataset.field;
+ for (let [key, value] of Object.entries(data.changes)) {
+ if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
- row.remove();
+ let input = Array.from(form.inputs.values())
+ .find(temp => temp.field?.dataset.field === key);
+ if (!input) continue;
- // Reindex remaining rows
- this.updateRepeaterOrder(repeater, formConfig);
+ let entry = refs.result.cloneNode(true);
+ let title = entry.querySelector('h3');
+ let p = entry.querySelector('p');
- // Schedule save
- if (formConfig) {
- this.scheduleSave(formConfig, {
- type: 'repeater',
- action: 'remove',
- fieldName: fieldName,
- delay: this.repeaterDelays.remove
- });
- }
-
- if (window.jvbA11y) {
- window.jvbA11y.announce('Row removed');
- }
- }
-
- /**
- * Update repeater order after sorting
- */
- updateRepeaterOrder(repeater, formConfig) {
- const container = repeater.querySelector('.repeater-items');
- const fieldName = repeater.dataset.field;
-
- // Reindex all rows
- Array.from(container.children).forEach((row, index) => {
- row.dataset.index = index;
-
- // Update field names
- row.querySelectorAll('input, select, textarea').forEach(field => {
- const parts = field.name.split(':');
- if (parts.length === 3) {
- const originalName = parts[2];
- field.name = `${fieldName}:${index}:${originalName}`;
- field.id = `${fieldName}-${index}-${originalName}`;
-
- // Update label
- const label = field.nextElementSibling;
- if (label && label.tagName === 'LABEL') {
- label.htmlFor = field.id;
- }
- }
- });
- });
-
- // Schedule save
- if (formConfig) {
- this.scheduleSave(formConfig, {
- type: 'repeater',
- action: 'reorder',
- fieldName: fieldName,
- delay: this.repeaterDelays.reorder
- });
- }
- }
-
- /**
- * Initialize tag list fields
- */
- initTagListFields(form, formConfig) {
- form.querySelectorAll('.field.tag-list').forEach(field => {
- const inputRow = field.querySelector('.tag-input-row');
- const addButton = field.querySelector('.add-tag-item');
- const tagsContainer = field.querySelector('.tag-items');
- const template = field.querySelector('.tag-template');
- const fieldName = field.dataset.field;
- const tagFormat = field.dataset.tagFormat || 'first_field';
-
- if (!inputRow || !addButton || !tagsContainer || !template) return;
-
- // Get all input fields in the input row (excluding the button)
- const getInputFields = () => {
- return Array.from(inputRow.querySelectorAll('input, select, textarea'))
- .filter(input => !input.closest('button'));
- };
-
- // Add tag handler
- const addTag = () => {
- const inputs = getInputFields();
- const data = {};
- let hasValue = false;
-
- // Collect values from inputs
- inputs.forEach(input => {
- const fieldName = input.name.replace('new_', '');
- const value = this.getFieldValue(input);
-
- if (value) hasValue = true;
- data[fieldName] = value;
- });
-
- if (!hasValue) {
- if (window.jvbA11y) {
- window.jvbA11y.announce('Please fill in at least one field', 'error');
- }
- inputs[0].focus();
- return;
- }
-
- // Validate required fields using data-required attribute
- const invalidField = inputs.find(input => {
- const isRequired = ('required' in input.dataset && input.dataset.required === '1');
- const value = this.getFieldValue(input);
- return isRequired && !value;
- });
-
- if (invalidField) {
- const fieldWrapper = invalidField.closest('.field');
- const fieldLabel = fieldWrapper?.querySelector('label')?.textContent || 'This field';
- this.showError(fieldWrapper, `${fieldLabel} is required.`);
-
- invalidField.focus();
- return;
- }
-
- for (let input of inputs) {
- let wrapper = field.closest('.field');
- if (!this.validateField(input, wrapper)){
- input.focus();
- return;
- }
- }
-
- // Clone template and populate
- const index = tagsContainer.children.length;
- const newTag = template.content.cloneNode(true).firstElementChild;
- newTag.dataset.index = index;
-
- // Update tag label
- const tagLabel = newTag.querySelector('.tag-label');
- if (tagLabel) {
- tagLabel.textContent = this.getTagDisplayText(data, tagFormat);
- }
-
- // Update hidden inputs
- newTag.querySelectorAll('input[type="hidden"]').forEach(input => {
- const fieldKey = input.dataset.field;
- input.name = `${fieldName}:${index}:${fieldKey}`;
- input.value = data[fieldKey] || '';
- });
-
- tagsContainer.appendChild(newTag);
-
- // Clear inputs
- inputs.forEach(input => {
- if (input.type === 'checkbox' || input.type === 'radio') {
- input.checked = false;
- } else {
- input.value = '';
- }
- let field = input.closest('.field');
- this.clearValidation(field);
- });
-
- // Focus first input
- if (inputs.length > 0) {
- inputs[0].focus();
- }
+ // 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();
- // Schedule save
- if (formConfig) {
- this.scheduleSave(formConfig, {
- type: 'tag_list',
- action: 'add',
- fieldName: fieldName,
- delay: this.autoSaveDefaults.delay
- });
- }
+ const formattedValue = form.formatValueForSummary(value, input);
- if (window.jvbA11y) {
- window.jvbA11y.announce('Item added');
- }
- };
-
- // Add button click
- addButton.addEventListener('click', addTag);
-
- // Enter key support on last input
- const inputs = getInputFields();
- if (inputs.length > 0) {
- // Tab through inputs, Enter on last one adds the tag
- inputs[inputs.length - 1].addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addTag();
- }
- });
-
- // Enter on other inputs moves to next field
- inputs.slice(0, -1).forEach((input, i) => {
- input.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- inputs[i + 1].focus();
+ 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;
}
- });
- });
- }
- // Remove tag handler
- tagsContainer.addEventListener('click', (e) => {
- if (e.target.closest('.remove-tag')) {
- const tag = e.target.closest('.tag-item');
- const tagText = tag.querySelector('.tag-label')?.textContent || 'Item';
-
- tag.remove();
-
- // Reindex remaining tags
- this.reindexTagList(tagsContainer, fieldName);
-
- // Schedule save
- if (formConfig) {
- this.scheduleSave(formConfig, {
- type: 'tag_list',
- action: 'remove',
- fieldName: fieldName,
- delay: this.autoSaveDefaults.delay
+ 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);
+ }
});
}
- if (window.jvbA11y) {
- window.jvbA11y.announce(`${tagText} removed`);
- }
+ refs.result?.remove();
+ data.config.element.after(el);
+ window.fade(data.config.element, false);
}
- });
- });
+ }
+ );
}
- /**
- * Reindex tag list items
- */
- reindexTagList(container, baseFieldName) {
- Array.from(container.children).forEach((tag, index) => {
- tag.dataset.index = index;
- tag.querySelectorAll('input[type="hidden"]').forEach(input => {
- const fieldKey = input.dataset.field;
- input.name = `${baseFieldName}:${index}:${fieldKey}`;
- });
+ 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))
+ .filter(input => !input.closest('.ql-clipboard'));
+ 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);
+ }
- /**
- * Get display text for tag based on format
- */
- getTagDisplayText(data, format) {
- const values = Object.values(data).filter(v => v);
+ if (!this.quillInstances.has(config.id)) {
+ this.quillInstances.set(config.id, new Set());
+ }
- if (values.length === 0) return 'New Item';
+ 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.ui.increase.contains(e.target)) {
+ change++;
+ } else if (conf.ui.decrease.contains(e.target)) {
+ change--;
+ }
+ if (change === 0) return;
+ let field = this.getField(e.target);
+ let step = conf.ui.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.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value);
+ conf.ui.input.value = (value + (step * change));
- switch (format) {
- case 'first_field':
- return values[0];
+ value = parseFloat(conf.ui.input.value);
- case 'all_fields':
- return values.join(', ');
-
- default:
- // Template format like "{name} ({email})"
- if (format.includes('{')) {
- let text = format;
- for (const [key, value] of Object.entries(data)) {
- text = text.replace(`{${key}}`, value);
- }
- return text;
- }
- // Use specific field
- return data[format] || values[0];
+ if (conf.ui.input.min && value < conf.ui.input.min) {
+ conf.ui.input.value = conf.ui.input.min;
+ conf.ui.decrease.disabled = true;
+ } else if (conf.ui.input.max && value > conf.ui.input.max) {
+ conf.ui.input.value = conf.ui.input.max;
+ conf.ui.increase.disabled = true;
+ } else {
+ if (conf.ui.decrease.disabled) conf.ui.decrease.disabled = false;
+ if (conf.ui.increase.disabled) conf.ui.increase.disabled = false;
}
}
+ checkForRepeaters(form) {
- /**
- * HTML escape helper
- */
- escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
+ 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,
+ rows: []
+ };
+
+ 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.field}:${index}:`, el, false, true);
+ });
+ }
+ },
+ );
+
+ 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)) {
+ this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
+ } else if (e.target.matches(this.selectors.repeater.remove)) {
+ this.removeRepeaterRow(e.target.closest('[data-index]'));
+ }
+ }
+ addRepeaterRow(repeater) {
+ let data = {};
+ data.repeater = repeater;
+ let config = this.repeaters.get(repeater.dataset.repeaterId);
+
+ let row = this.templates.create(repeater.dataset.repeaterId, data);
+ config.rows.push({
+ element: row,
+ fields: Array.from(row.querySelectorAll('[data-field]'))
+ });
+ this.repeaters.set(config.id, config);
+ config.ui.items.append(row);
+
+ let form = this.getForm(repeater);
+ this.initializeFields(repeater, form);
+ 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;
+ config.fieldName = field.dataset.field;
+
+ 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 = input.closest('.tag-item');
+ window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper, false, true)
+ });
+
+ if (refs.label) {
+ refs.label.textContent = data.label;
+ }
+ }
+ },
+ );
+ config.ui.inputs = Array.from(field.querySelectorAll(this.selectors.tagList.inputs));
+ config.ui.value = Array.from(field.querySelectorAll(this.selectors.tagList.value));
+ this.tagLists.set(config.id, config);
+ this.addTagListListeners(field);
+ });
+
+ }
+ addTagListListeners(el) {
+ el.addEventListener('click', this.tagListClick);
+ el.addEventListener('keypress', this.tagListInput);
+ }
+ removeTagListListeners(el) {
+ el.removeEventListener('click', this.tagListClick);
+ el.removeEventListener('keypress', this.tagListInput);
}
- /**
- * Initialize conditional fields
- */
- initConditionalFields(form, formConfig) {
- form.querySelectorAll('[data-depends-on]').forEach(field => {
+ handleTagListClick(e) {
+ if (window.targetCheck(e,this.selectors.tagList.add)) {
+ this.addTagListItem(e.target.closest('[data-tag-list-id]'));
+ } else if (window.targetCheck(e, this.selectors.tagList.remove)) {
+ this.removeTagListItem(e.target.closest(this.selectors.tagList.item));
+ }
+ }
+ addTagListItem(tagList) {
+ let config = this.tagLists.get(tagList.dataset.tagListId);
+ if (!config) return;
+
+ let data = {};
+ let hasValue = false;
+ let isValid = true;
+
+ // First pass: validate all inputs
+ for (let input of config.ui.inputs) {
+ const isRequired = input.required || input.dataset.required === 'true';
+ const value = this.getFieldValue(input);
+
+ if (value) hasValue = true;
+
+ // Validate and check for errors
+ const valid = this.validateField(input);
+
+ if (isRequired && !value) {
+ this.showError(input, 'This field is required');
+ isValid = false;
+ } else if (!valid) {
+ isValid = false;
+ }
+
+ const fieldName = input.name.replace('new_','');
+ data[fieldName] = value;
+ }
+
+ // Stop if validation failed
+ if (!isValid) {
+ this.a11y.announce('Please correct the errors before adding');
+ const firstInvalid = config.ui.inputs.find(input => {
+ const isRequired = input.required || input.dataset.required === 'true';
+ return (isRequired && !this.getFieldValue(input));
+ });
+ if (firstInvalid) firstInvalid.focus();
+ return;
+ }
+
+ if (!hasValue) {
+ this.a11y.announce('Please fill in at least one field');
+ config.ui.inputs[0].focus();
+ return;
+ }
+
+ // Build label
+ 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 (config.format.includes('{')) {
+ 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,
+ fieldName: config.fieldName
+ });
+
+ const index = config.ui.items?.children?.length ?? 0;
+ newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => {
+ const fieldKey = input.dataset.field;
+ input.name = `${config.fieldName}:${index}:${fieldKey}`;
+ input.id = `${config.fieldName}:${index}:${fieldKey}`;
+ input.value = data[fieldKey] || '';
+ });
+
+ config.ui.items.append(newItem);
+
+ // Clear inputs AFTER success
+ for (let input of config.ui.inputs) {
+ if (['checkbox', 'radio'].includes(input.type)) {
+ input.checked = false;
+ } else {
+ input.value = '';
+ }
+ this.clearValidation(input);
+ }
+
+ config.ui.inputs[0]?.focus();
+ this.updateCollectionField(tagList);
+ this.a11y.announce('Item added');
+ }
+ removeTagListItem(item) {
+ let tagList = item.closest('[data-tag-list-id]');
+ if (!tagList) return;
+ item.remove();
+ this.reindexList(tagList);
+ this.updateCollectionField(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.dependsOperator || '==';
+ const operator = field.dataset.dependsOperatior??'==';
- // Store dependency
- if (!formConfig.dependencies.has(dependsOn)) {
- formConfig.dependencies.set(dependsOn, []);
+ let formData = this.forms.get(form.dataset.formId);
+
+ if (!this.dependencies.has(dependsOn)) {
+ if (Object.hasOwn(formData.ui.fields, dependsOn)) {
+ this.dependencies.set(dependsOn, []);
+ }
}
- formConfig.dependencies.get(dependsOn).push({
- field: field,
- requiredValue: requiredValue,
- operator: operator
- });
+ let dependency = this.dependencies.get(dependsOn);
+ if (dependency) {
+ dependency.push({
+ field: field,
+ form: form.dataset.formId,
+ requiredValue: requiredValue,
+ operator: operator
+ });
+ this.dependencies.set(dependsOn, dependency);
+ }
- // Check initial state
- this.checkFieldDependency(form, field, dependsOn, requiredValue, operator);
+ this.checkFieldDependency(field, dependsOn);
});
}
+ checkFieldDependency(dependentField, controlFieldName) {
+ const form = this.getForm(dependentField);
+ const controlField = this.dependencies.get(controlFieldName);
+ if (!controlField) return;
- /**
- * 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);
+ const controlValue = this.getFieldValue(form.ui.fields[controlFieldName]);
+ const shouldShow = this.evaluateCondition(
+ controlValue,
+ dependentField.dataset.dependsValue,
+ dependentField.dataset.dependsOperatior
+ );
- this.toggleFieldVisibility(field, shouldShow);
+ this.toggleFieldVisibility(dependentField, shouldShow);
}
-
- /**
- * Evaluate conditional operator
- */
evaluateCondition(value, requiredValue, operator) {
const fieldStr = String(value || '');
const requiredStr = String(requiredValue || '');
@@ -897,10 +1205,6 @@
default: return fieldStr === requiredStr;
}
}
-
- /**
- * Toggle field visibility
- */
toggleFieldVisibility(field, show) {
const wrapper = field.closest('.field, fieldset');
if (!wrapper) return;
@@ -917,71 +1221,226 @@
}
});
}
+ checkForCharacterLimits(form) {
+ if (!form.querySelector(this.selectors.limits.hasLimit)) return;
+ this.countUpdaters = this.updateCount.bind(this);
- /**
- * Initialize character limits
- */
- initCharacterLimits(form) {
- form.querySelectorAll('[data-limit]').forEach(input => {
- const limit = parseInt(input.dataset.limit, 10);
- const field = input.closest('.field');
+ form.querySelectorAll(this.selectors.limits.hasLimit).forEach(field => {
+ const input = this.getFieldInput(field);
+ if (!input) return;
- // 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);
- }
+ let id = window.generateID('limit');
+ input.dataset.charLimitId = id;
+ input.dataset.limit = field.dataset.maxlength;
- 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;
- }
- }
+ let config = {
+ element: input,
+ form: form.dataset.formId,
+ ui: window.uiFromSelectors(this.selectors.limits, field)
};
- input.addEventListener('input', updateCount);
- updateCount(); // Initial count
+ if (config.ui.limit) {
+ config.ui.limit.textContent = field.dataset.maxlength;
+ }
+
+ 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) {
+ this.hasUploads = true;
+ window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta);
+ let uploads = form.querySelectorAll('[data-field-type="upload"]');
+ if (uploads) {
+ config.ui.uploads = {};
+ uploads.forEach(upload => {
+ config.ui.uploads[upload.dataset.field] = upload;
+ });
+ }
+ }
+
+ 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,
+ false,
+ true
+ );
+ });
+ });
+
+
+ this.updateCollectionField(container);
+ }
/**
- * Initialize image upload fields
+ * Update the entire repeater/tagList field data
+ * Call this whenever rows are added, removed, or reordered
*/
- initImageUploadFields(form, config) {
- window.jvbUploads.scanFields(form, config.options.autoUpload);
+ 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);
+ 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 = '';
+ }
}
- /* ========== Event Handlers ========== */
+ showError(input, message = 'Invalid field') {
+ let field = this.getField(input);
+ if (!field) return;
+ let item = this.getItem(input);
+ if (!item) return;
- async handleSubmit(event) {
- const form = event.target;
+ field.classList.remove('has-success');
+ field.classList.add('has-error');
- if (!form.dataset.formId) return;
- const formConfig = this.forms.get(form.dataset.formId);
+ if (item.ui.message) {
+ item.ui.message.hidden = false;
+ item.ui.message.textContent = message;
+ }
+ }
- // Handle subscriber-based forms
- if (this.subscribers.size > 0) {
- event.preventDefault();
- const formData = this.collectFormData(form);
+ showSuccess(input, message = '') {
+ let field = this.getField(input);
+ if (!field) return;
+ let item = this.getItem(input);
+ if (!item) return;
- // Notify subscribers (they'll handle actual submission)
- this.notify('form-submit', {
- formId: form.dataset.formId,
- fullData: formData,
- config: formConfig
- });
+ field.classList.remove('has-error');
+ field.classList.add('has-success');
+
+ if (item.ui.message) {
+ item.ui.message.hidden = message=== '';
+ item.ui.message.textContent = message;
}
}
@@ -1054,11 +1513,6 @@
if (window.jvbA11y) {
window.jvbA11y.announce(data.message || 'Form submitted successfully');
}
-
- // Trigger custom event
- form.dispatchEvent(new CustomEvent('jvb-form-success', {
- detail: data
- }));
}
handleFormError(form, data) {
@@ -1077,28 +1531,20 @@
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');
@@ -1106,12 +1552,9 @@
}
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}`
@@ -1119,548 +1562,30 @@
window.jvbA11y.announce(announcement);
}
- // Trigger custom event
form.dispatchEvent(new CustomEvent('jvb-form-error', {
detail: data
}));
}
- handleClick(e) {
- if (window.targetCheck(e, 'div.quantity')) {
- let container = window.targetCheck(e, 'div.quantity');
- this.handleNumberClick(e, container.querySelector('input'));
- } else if (window.targetCheck(e, '[data-action]')) {
- let actionEl = window.targetCheck(e, '[data-action]');
- let action = actionEl.dataset.action;
- let form = actionEl.closest('form');
-
- switch (action) {
- case 'clear-form':
- if (form?.dataset.formId) {
- this.store.delete(form.dataset.formId);
- form.reset();
- // Hide the status message
- form.querySelector('.fstatus').hidden = true;
- }
- if (window.jvbA11y) {
- window.jvbA11y.announce('Form cleared, starting fresh');
- }
- break;
-
- case 'dismiss-restore':
- form.querySelector('.fstatus').hidden = true;
- break;
- }
- }
- }
-
-
- handleNumberClick(e, input) {
- let change = 0;
-
- if (e.target.closest('.increase')) {
- change += 1;
- } else if (e.target.closest('.decrease')) {
- change -=1;
- }
- if (change !== 0) {
- let step = parseFloat(input.step);
- //Allow for cents, but default to increasing by 1
- step = Math.max(step, 1);
-
- if(e.ctrlKey && e.shiftKey) {
- step = step * 50;
- } else if (e.ctrlKey) {
- step = step * 5;
- } else if (e.shiftKey) {
- step = step * 10;
- }
-
- let value = (input.value === '') ? 0 : parseFloat(input.value);
-
- input.value = (value + (step * change));
- this.handleNumberLimits(input);
- }
- }
-
- handleNumberLimits(input) {
- let [
- min,
- max,
- increase,
- decrease
- ] = [
- input.min,
- input.max,
- input.closest('.quantity')?.querySelector('.increase'),
- input.closest('.quantity')?.querySelector('.decrease')
- ];
- let value = parseFloat(input.value);
- if (value < min) {
- input.value = min;
- decrease.disabled = true;
- } else if (value > max) {
- input.value = max;
- increase.disabled = false;
- } else if (increase.disabled) {
- increase.disabled = false;
- } else if (decrease.disabled) {
- decrease.disabled = false;
- }
- }
-
- handleChange(event) {
- if (event.target.closest('[data-ignore]') || this.isRestoring) {
- return;
- }
- const target = event.target;
- const form = target.form || target.closest('form');if (!form) return;
-
- const formConfig = this.forms?.get(form.dataset.formId);
- if (!formConfig) return;
-
- if (formConfig.options.autosave || this.subscribers.size > 0) {
- // Check conditional fields
- const dependencies = formConfig.dependencies.get(target.name);
- if (dependencies) {
- dependencies.forEach(dep => {
- this.checkFieldDependency(form, dep.field, target.name, dep.requiredValue, dep.operator);
- });
- }
-
- // Schedule auto-save if enabled
- const delay = this.getDelayForField(target);
- this.scheduleSave(formConfig, delay);
- }
- }
-
- handleBlur(e) {
- if (e.target.closest('[data-ignore]') || this.isRestoring) {
- return;
- }
- const target = e.target;
- const form = target.form || target.closest('form');
-
- if (!form) return;
-
-
- const input = e.target.closest('input, textarea, select');
- if (input) {
- const fieldWrapper = this.findFieldWrapper(input);
- if (fieldWrapper) {
- // Mark as touched and validate
- const fieldName = fieldWrapper.dataset.field;
- if (fieldName) {
- if (this.shouldDebounce(input)) {
- window.debouncer.cancel(`validate_${fieldName}`);
- }
- this.touchedFields.add(fieldName);
- }
- this.validateField(input, fieldWrapper);
- }
- const formConfig = this.forms?.get(form.dataset.formId);
- if (formConfig) {
- // Shorter delay on blur
- this.scheduleSave(formConfig, {
- type: 'blur',
- fieldName: target.name,
- delay: 1500
- });
- }
- }
- }
-
- handleInput(e) {
- if (e.target.closest('[data-ignore]') || ! e.target.closest('form') || this.isRestoring) {
- return;
- }
- const input = e.target.closest('input, textarea, select');
- if (!input) return;
-
- let form = input.closest('form');
- this.showFormStatus(form.dataset.formId, 'pending');
-
- const fieldWrapper = this.findFieldWrapper(input);
- if (!fieldWrapper) return;
-
- const fieldName = fieldWrapper.dataset.field;
- if (fieldName) {
- this.touchedFields.add(fieldName);
- }
-
- if (this.shouldDebounce(input)){
- window.debouncer.schedule(
- `validate_${fieldName}`,
- () => this.validateField.bind(this),
- 500
- )
- }
- }
-
- /***************************************************************
- FORM VALIDATION
- ***************************************************************/
- /**
- * Initialize validation rules
- */
- initValidators() {
- return {
- email: {
- pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
- message: 'Please enter a valid email address'
- },
- url: {
- pattern: /^https?:\/\/.+\..+/,
- message: 'Please enter a valid URL starting with https://'
- },
- phone: {
- pattern: /^[\d\s\-+().]+$/,
- message: 'Please enter a valid phone number'
- },
- number: {
- test: (value, fieldWrapper) => {
- const num = parseFloat(value);
- if (isNaN(num)) return 'Please enter a valid number';
-
- const min = fieldWrapper.dataset.min;
- const max = fieldWrapper.dataset.max;
-
- if (min !== undefined && num < parseFloat(min)) {
- return `Value must be at least ${min}`;
- }
- if (max !== undefined && num > parseFloat(max)) {
- return `Value must be at most ${max}`;
- }
- return true;
- }
- },
- text: {
- test: (value, fieldWrapper) => {
- const minLength = fieldWrapper.dataset.minlength;
- const maxLength = fieldWrapper.dataset.maxlength;
-
- if (minLength && value.length < parseInt(minLength)) {
- return `Must be at least ${minLength} characters`;
- }
- if (maxLength && value.length > parseInt(maxLength)) {
- return `Must be no more than ${maxLength} characters`;
- }
- return true;
- }
- }
- };
- }
- /**
- * Find the field wrapper (handles both simple and complex fields)
- */
- findFieldWrapper(input) {
- // Try to find the closest .field wrapper
- let wrapper = input.closest('.field');
-
- // If we're in a repeater row, make sure we get the right field wrapper
- if (!wrapper) {
- wrapper = input.closest('[data-field]');
- }
-
- return wrapper;
- }
-
- /**
- * Check if input should be debounced
- */
- shouldDebounce(input) {
- const debounceTypes = ['text', 'email', 'url', 'tel', 'search'];
- return debounceTypes.includes(input.type) || input.tagName === 'TEXTAREA';
- }
-
- /**
- * Validate a single field
- */
- validateField(input, fieldWrapper) {
- const value = this.getFieldValue(input);
- const fieldName = fieldWrapper.dataset.field;
-
- // Skip validation if field hasn't been touched yet (unless it's required)
- if (!this.touchedFields.has(fieldName) && !input.required) {
- return true;
- }
-
- // Skip validation if field is empty and not required
- if (!value && !input.required) {
- this.clearValidation(fieldWrapper);
- return true;
- }
-
- // Check required
- if (input.required && !value) {
- this.showError(fieldWrapper, 'This field is required');
- return false;
- }
-
- // Check HTML5 validity first
- if (input.checkValidity && !input.checkValidity()) {
- this.showError(fieldWrapper, input.validationMessage);
- return false;
- }
-
- // Custom pattern validation from data attribute
- const pattern = fieldWrapper.dataset.pattern;
- if (pattern && value) {
- const regex = new RegExp(pattern);
- if (!regex.test(value)) {
- const message = fieldWrapper.dataset.validationMessage || 'Invalid format';
- this.showError(fieldWrapper, message);
- return false;
- }
- }
-
- // Type-specific validation
- const validateType = fieldWrapper.dataset.validate || input.type;
- if (validateType && this.validators[validateType]) {
- const validator = this.validators[validateType];
-
- if (validator.pattern && !validator.pattern.test(value)) {
- this.showError(fieldWrapper, validator.message);
- return false;
- }
-
- if (validator.test) {
- const result = validator.test(value, fieldWrapper);
- if (result !== true) {
- this.showError(fieldWrapper, result);
- return false;
- }
- }
- }
-
- // All validations passed
- this.showSuccess(fieldWrapper);
- this.notify('field-validated', input);
- return true;
- }
-
-
-
- /**
- * Show success state (green checkmark)
- */
- showSuccess(fieldWrapper, textMessage = '') {
- if (!fieldWrapper) return;
-
- // Find validation elements (they might be in field-input-wrapper or field-content)
- const success = fieldWrapper.querySelector('.validation-icon.success');
- const error = fieldWrapper.querySelector('.validation-icon.error');
- const message = fieldWrapper.querySelector('.validation-message');
- const input = fieldWrapper.querySelector('input, textarea, select');
-
- // Remove error state
- fieldWrapper.classList.remove('has-error');
- input?.classList.remove('error');
-
- // Add success state
- fieldWrapper.classList.add('has-success');
-
- // Show checkmark (if element exists)
- if (success) {
- success.hidden = false;
- }
- if (error) {
- error.hidden = true;
- }
-
- // Hide error message
- if (message) {
- if (textMessage === '') {
- message.hidden = true;
- message.textContent = '';
- } else {
- message.hidden = false;
- message.textContent = textMessage;
- }
- }
- }
-
- /**
- * Show error state (red message below field)
- */
- showError(fieldWrapper, errorMessage) {
- if (!fieldWrapper) return;
-
- const success = fieldWrapper.querySelector('.validation-icon.success');
- const error = fieldWrapper.querySelector('.validation-icon.error');
- const message = fieldWrapper.querySelector('.validation-message');
- const input = fieldWrapper.querySelector('input, textarea, select');
-
- // Remove success state
- fieldWrapper.classList.remove('has-success');
-
- // Add error state
- fieldWrapper.classList.add('has-error');
- input?.classList.add('error');
-
- // Hide checkmark (if element exists)
- if (success) {
- success.hidden = true;
- }
- //show x
- if (error) {
- error.hidden = false;
- }
-
- // Show error message
- if (message) {
- message.hidden = false;
- message.textContent = errorMessage;
- }
- }
-
- /**
- * Clear validation state
- */
- clearValidation(fieldWrapper) {
- if (!fieldWrapper) return;
-
- const icon = fieldWrapper.querySelector('.validation-icon');
- const message = fieldWrapper.querySelector('.validation-message');
- const input = fieldWrapper.querySelector('input, textarea, select');
-
- fieldWrapper.classList.remove('has-error', 'has-success');
- input?.classList.remove('error');
-
- if (icon) {
- icon.hidden = true;
- }
-
- if (message) {
- message.hidden = true;
- message.textContent = '';
- }
- }
-
- /* ========== Auto-save functionality ========== */
- /**
- * Get appropriate delay based on field type and context
- */
- getDelayForField(field) {
- // Text fields get longer delay for typing
- if (field.type === 'text' || field.type === 'textarea') {
- return this.autoSaveDefaults.typingDelay;
- }
-
- // Checkboxes, radios, selects get shorter delay
- if (['checkbox', 'radio', 'select-one', 'select-multiple'].includes(field.type)) {
- return 1000;
- }
-
- // Default delay
- return this.autoSaveDefaults.delay;
- }
- scheduleSave(formConfig, delay = this.autoSaveDefaults.delay) {
- if (!formConfig.options.autosave) {
- return;
- }
- document.addEventListener('input', this.saveCheck, {passive: true});
- const saveKey = `autosave_${formConfig.id}`;
-
- this.debouncer.schedule(
- saveKey,
- () => this.autosave(formConfig),
- delay
- );
- }
-
- //Extend delay if user is currently typing
- saveCheck(e) {
- let form = e.target.closest('form[data-id]');
- if (!form) {
- return;
- }
-
- this.scheduleSave(this.forms.get(form.dataset.id));
- }
-
- async autosave(formConfig) {
- const formData = this.collectFormData(formConfig.element);
-
- this.showFormStatus(formConfig.id, 'saving');
-
- // DataStore will now automatically:
- // - Convert Sets/Maps to Arrays/Objects
- // - Strip DOM references
- // - Validate serializability
- await this.store.save({
- formId: formConfig.id,
- data: formData,
- status: 'draft',
- timestamp: Date.now()
- }).then(() => {
- this.showFormStatus(formConfig.id, 'autosaved');
- }).catch(error => {
- console.error('Autosave failed:', error);
- this.showFormStatus(formConfig.id, 'error', 'Failed to save changes');
- });
-
- // Get only changed fields
- const changes = this.getChangedFields(formConfig.data, formData);
- if (Object.keys(changes).length === 0) return;
-
- // Update stored data
- formConfig.data = formData;
- this.forms.set(formConfig.id, formConfig);
- document.removeEventListener('input', this.handleInput);
-
- for (let [key, value] of Object.entries(formData)) {
- // Complex fields need full data
- if (typeof value === 'object') {
- changes[key] = value;
- }
- }
-
- // Notify
- this.notify('form-autosave', {
- formId: formConfig.id,
- changes: changes,
- fullData: formData,
- config: formConfig
- });
- }
-
- /**
- * Check if form has unsaved changes
- */
- hasUnsavedChanges(formId) {
- const formConfig = this.forms.get(formId);
- if (!formConfig) return false;
-
- // Check if there are pending operations
- if (formConfig.operations?.size > 0) return true;
-
- // Check if current data differs from snapshot
- const currentData = this.collectFormData(formConfig.element);
- const changes = this.getChangedFields(formConfig.data, currentData);
-
- return Object.keys(changes).length > 0;
- }
-
- showFormStatus(formID, status, message='') {
- let form = this.forms.get(formID);
- if (!form?.options.formStatus) {
- return;
- }
+ /**********************************************************************
+ 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;
- 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));
- const statusWrap = form.element.querySelector('.fstatus');
- statusWrap.hidden = false;
- const statusElement = statusWrap.querySelector('.message');
- statusElement.textContent = '';
- statusWrap.querySelector('.icon')?.remove();
- statusWrap.querySelector('.actions')?.remove(); // Clear old actions
+ 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.',
@@ -1671,7 +1596,9 @@
'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',
@@ -1680,791 +1607,507 @@
'offline': 'cloud-slash',
'pending': 'exclamation-mark'
}
-
- let icon = window.getIcon(icons[status]);
- if (icon) {
- statusWrap.prepend(icon);
- }
-
- if (message === '') {
- message = messages[status] || status;
- }
- statusElement.textContent = message;
- statusWrap.classList.toggle('loading', ['uploading', 'saving'].includes(status));
-
- // Add action buttons for certain statuses
- if (status === 'restored') {
- const actions = document.createElement('div');
- actions.className = 'actions';
- actions.innerHTML = `
- <button type="button" class="button button-small" data-action="dismiss-restore">Got it</button>
- <button type="button" class="button button-small button-link" data-action="clear-form">Start over</button>
- `;
- statusWrap.appendChild(actions);
-
- // Auto-dismiss after 10 seconds
- setTimeout(() => statusWrap.hidden = true, 10000);
- }
-
- // Auto-hide success messages
- if (status === 'submitted') {
- setTimeout(() => statusWrap.hidden = true, 3000);
- }
+ return icons[status]??'';
}
- 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();
+ /**********************************************************************
+ SUMMARY
+ **********************************************************************/
+ showSummary(data) {
+ let summary = this.templates.create('formSummary', data);
+ data.config.element.after(summary);
+ window.fade(data.config.element, false);
}
-
- /* ========== Form Data Methods ========== */
-
- collectFormData(form) {
- if (Object.hasOwn(form.dataset, 'timeline')) {
- return this.collectTimeline(form);
- }
- //Table forms are handled separately
- if (form.classList.contains('table') && form.tagName === 'FORM') {
- return {};
- }
-
- const formData = new FormData(form);
- let data = {};
- const repeaterData = {};
- const postData = {};
-
- for (let [key, value] of formData.entries()) {
- if (this.ignore.includes(key) || key.endsWith('_temp')) continue;
-
- const processor = this.getFieldProcessor(key);
- processor(key, value, data, repeaterData, postData, form);
- }
- if (Object.keys(postData).length !== 0) {
- data = this.mergeRepeaterData(data, repeaterData);
- return this.mergePostData(data, postData);
- }
- return this.mergeRepeaterData(data, repeaterData);
+ /**********************************************************************
+ UTILITY
+ **********************************************************************/
+ getForm(element) {
+ let form = element.closest('[data-form-id]');
+ if (!form) return false;
+ let id = form.dataset.formId;
+ if (!id) return false;
+ let config = this.forms.get(id);
+ if (!config) return false;
+ return config;
}
-
- collectTimeline(form) {
- let data = {};
- let posts = {}; // Temporary object keyed by post ID
- let postOrder = []; // Track order as encountered (preserves DOM/drag order)
- let formData = new FormData(form);
-
- for (const [key, value] of formData.entries()) {
- if (this.ignore.includes(key) || key.endsWith('_temp')) {
- continue;
- }
- const match = key.match(/^\[(\d+)](.+)$/);
- if (match) {
- // Timeline-specific field: [postId]fieldName
- const [, postId, fieldName] = match;
- if (!posts[postId]) {
- posts[postId] = {
- id: parseInt(postId),
- };
- postOrder.push(postId); // Track first occurrence
- }
- if (fieldName === 'post_thumbnail') {
- posts[postId]['post_thumbnail'] = parseInt(form.querySelector(`[name="${key}"]`).closest('.item')?.dataset.id);
- } else {
- const processor = this.getFieldProcessor(fieldName);
- processor(fieldName, value, posts[postId], {}, {}, form);
- }
-
- } else {
- // Shared field (post_title, taxonomies, etc.)
- const processor = this.getFieldProcessor(key);
- processor(key, value, data, {}, {}, form);
- }
- }
-
- // Convert to array in DOM order (matches menu_order)
- data.timeline = postOrder.map(id => posts[id]);
-
- delete data['form-id'];
- delete data['sendAll'];
- delete data['timeline_temp'];
- delete data['']; // Empty key
-
- return data;
+ getField(element) {
+ return element.closest('[data-field]');
}
-
- getFieldProcessor(key) {
- if (key.includes('::')) return this.processGroupField;
- if (key.includes(':')) return this.processRepeaterField;
- if (/\[[^\]]+]/.test(key)) return this.processLocationField;
- return this.processRegularField;
+ 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);
- 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;
- }
- });
+ let fieldName = conf.field?.dataset.field??false;
+ if (!fieldName) return false;
- // Convert to sequential array
- data[fieldName] = Object.values(cleanedRows);
- });
- return data;
- }
-
- mergePostData(data, postData) {
- for (let [postId, fields] of Object.entries(postData)) {
- data[postId] = fields;
- }
- return data;
- }
-
- processRepeaterField(key, value, data, repeaterData, postData, form) {
- let [fieldName, index, subField] = key.split(':');
-
- const isArray = subField.endsWith('[]');
- subField = subField.replace('[]', '');
-
- //Ensure this repeater and row is in repeaterData
- if (!repeaterData[fieldName]) {
- repeaterData[fieldName] = {};
- }
- if (!repeaterData[fieldName][index]) {
- repeaterData[fieldName][index] = {};
- }
-
- if (isArray || repeaterData[fieldName][index][subField]) {
- // Initialize as array if not already
- if (!repeaterData[fieldName][index][subField]) {
- repeaterData[fieldName][index][subField] = [];
- } else if (!Array.isArray(repeaterData[fieldName][index][subField])) {
- repeaterData[fieldName][index][subField] = [repeaterData[fieldName][index][subField]];
- }
- repeaterData[fieldName][index][subField].push(value);
- } else {
- // Single value field
- repeaterData[fieldName][index][subField] = value;
- }
- }
- processGroupField(key, value, data, repeaterData, postData, form) {
- const keys = key.split('::');
- const rootGroup = keys[0];
-
- // Initialize root group if it doesn't exist
- if (!data[rootGroup]) {
- data[rootGroup] = {};
- }
-
- // Build nested structure step by step
- let current = data[rootGroup];
- for (let i = 1; i < keys.length - 1; i++) {
- const groupKey = keys[i];
- if (!current[groupKey]) {
- current[groupKey] = {};
- }
- current = current[groupKey];
- }
-
- // Set the final field value
- const fieldKey = keys[keys.length - 1];
-
- // Handle array values (checkboxes, multi-selects)
- if (current[fieldKey] !== undefined) {
- if (!Array.isArray(current[fieldKey])) {
- current[fieldKey] = [current[fieldKey]];
- }
- current[fieldKey].push(value);
- } else {
- current[fieldKey] = value;
- }
- }
- processLocationField(key, value, data, repeaterData, postData, form) {
- let [fieldKey, v ] = key.split('[');
- v = v.replace(']','');
- if (!Object.hasOwn(data, fieldKey)) {
- data[fieldKey] = {};
-
- if (!Object.hasOwn(data, 'sendAll')) {
- data['sendAll'] = [fieldKey];
- } else if (!data['sendAll'].includes(fieldKey)) {
- data['sendAll'].push(fieldKey);
- }
- }
- data[fieldKey][v] = value;
- }
-
- processRegularField(key, value, data, repeaterData, postData, form) {
- //handle array values (like checkboxes/selects)
- key = key.replace('[]','');
- if (data[key]) {
- if (!Array.isArray(data[key])) {
- data[key] = [data[key]];
- }
- data[key].push(value);
- } else {
- data[key] = value;
- }
- }
-
- /**
- * Get field value (handles different input types)
- */
- getFieldValue(input) {
- if (!input) return '';
-
- if (input.type === 'checkbox') {
- return input.checked ? input.value || '1' : '';
- } else if (input.type === 'radio') {
- const checked = input.form?.querySelector(`[name="${input.name}"]:checked`);
- return checked ? checked.value : '';
- } else if (input.type === 'select-multiple') {
- return Array.from(input.selectedOptions).map(o => o.value);
- }
-
- return input.value?.trim() || '';
- }
-
- getChangedFields(original, current) {
- return window.getDifferences?.map(original, current) || {};
- }
-
- /*******************************************************
- Field Summary
- *******************************************************/
- /**
- * Show a comprehensive summary of form submission
- */
- showSummary(formId, clear = 'form') {
- const formConfig = this.forms.get(formId);
- if (!formConfig) return;
-
- const form = formConfig.element || document.querySelector(`[data-form-id="${formId}"]`);
- const summary = window.getTemplate('formSummary');
- if (!summary) return;
- const wrapper = summary.querySelector('.result');
-
- // Fields to skip in summary
- const skipFields = ['sendAll', ...this.ignore];
-
- // Process each field in the form data
- for (const [key, value] of Object.entries(formConfig.data)) {
- // Skip ignored fields and empty values
- if (skipFields.includes(key) || this.isEmptyValue(value)) {
- continue;
- }
-
- // Get field info from form
- const fieldInfo = this.getFieldInfo(form, key);
-
- if (!fieldInfo.label) continue; // Skip if no label found
-
- let field = wrapper.cloneNode(true);
- let title = field.querySelector('h3');
- let p = field.querySelector('p');
-
- title.textContent = fieldInfo.label;
-
-
- let formatted = this.formatFieldValue(value, fieldInfo.type, form);
- if (this.isHtmlContent(formatted)) {
- p.innerHTML = formatted;
- } else {
- p.textContent = formatted;
- }
-
- summary.append(field);
- }
- let uploads = form.querySelectorAll('[data-upload-field]');
- if (uploads) {
- uploads.forEach(upload => {
- let label = upload.querySelector('h2').textContent;
-
- let imgs = upload.querySelectorAll('.item-grid.preview img');
- if (imgs) {
- let field = wrapper.cloneNode(true);
- let title = field.querySelector('h3');
- let p = field.querySelector('p');
- p.remove();
-
- title.textContent = label;
- imgs.forEach(img => {
- img = img.cloneNode(true);
- field.append(img);
- });
- summary.append(field);
- }
- });
- }
-
- // Remove template
- wrapper.remove();
-
- // Insert summary and hide form
- clear = (clear !== 'form') ? form.closest(clear)??form : form;
-
- clear.after(summary);
- window.fade(clear, false);
- }
-
- /**
- * Check if a value is empty (null, undefined, empty string, empty array, empty object)
- */
- isEmptyValue(value) {
- if (value === null || value === undefined || value === '') {
- return true;
- }
- if (Array.isArray(value) && value.length === 0) {
- return true;
- }
- return typeof value === 'object' && Object.keys(value).length === 0;
-
- }
-
- /**
- * Get field information (label, type, etc.) from the form
- * Handles special field name patterns ([], ::, :, etc.)
- */
- getFieldInfo(form, fieldName) {
- // Try to find label by 'for' attribute (exact match)
- let label = form.querySelector(`label[for="${fieldName}"]`);
- let input = form.querySelector(`[name=${fieldName}]`);
- // Try to find the input field - check multiple patterns
- if (!input) {
- // Try exact match first
- input = form.querySelector(`[name="${fieldName}"]`);
- }
-
- if (!input) {
- // Try with [] suffix (for checkboxes, multi-selects)
- input = form.querySelector(`[name="${fieldName}[]"]`);
- }
-
- if (!input) {
- // Try as fieldset legend (for checkbox/radio groups)
- const fieldset = form.querySelector(`fieldset[data-field="${fieldName}"]`);
- if (fieldset) {
- label = fieldset.querySelector('legend');
- input = fieldset.querySelector('input, select, textarea');
- }
- }
-
- // Get label from input if not found yet
- if (!label && input) {
- // Try closest field wrapper first
- const field = input.closest('.field, fieldset');
- if (field) {
- label = field.querySelector('label, legend, h2');
- }
- }
-
- // Get field wrapper - always use base name (no special characters)
- let fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`);
-
- // Determine field type
- let fieldType = 'text';
- if (fieldWrapper?.dataset.type) {
- fieldType = fieldWrapper.dataset.type;
- } else if (input) {
- // Infer from input type
- if (input.type === 'checkbox' && input.name.endsWith('[]')) {
- fieldType = 'checkbox'; // checkbox group
- } else if (input.type === 'checkbox') {
- fieldType = 'true_false'; // single checkbox
- } else if (input.tagName === 'SELECT' && input.multiple) {
- fieldType = 'select'; // multi-select
- } else {
- fieldType = input.type || 'text';
- }
- }
-
- return {
- label: label?.textContent.replace('*', '').trim() || null,
- type: fieldType,
- wrapper: fieldWrapper,
- input: input
- };
- }
-
- /**
- * Check if content should be treated as HTML
- */
- isHtmlContent(content) {
- return typeof content === 'string' && (
- content.includes('<br>') ||
- content.includes('<p>') ||
- content.includes('<ul>') ||
- content.includes('<ol>') ||
- content.includes('<a ') ||
- content.includes('<strong>') ||
- content.includes('<em>') ||
- content.includes('<div')
- );
- }
-
- /**
- * Format field value based on type
- */
- formatFieldValue(value, type, form) {
switch (type) {
- case 'textarea':
- case 'wysiwyg':
- // Handle rich text - check if it's actual HTML content from Quill
- return this.formatTextareaValue(value, type);
-
- case 'true_false':
- return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No';
- case 'checkbox':
- // Handle both single checkbox and checkbox groups
- if (Array.isArray(value)) {
- return this.formatArrayValue(value);
- }
- return (value === '1' || value === 1 || value === true) ? 'Yes' : value;
-
- case 'select':
- // Handle both single and multi-select
- if (Array.isArray(value)) {
- return this.formatArrayValue(value);
- }
- // Get label from select option
- return this.getSelectLabel(value, form, type);
- case 'date':
- case 'datetime':
- case 'time':
- return window.formatDate ? window.formatDate(value) : value;
-
- case 'radio':
- // Get label from select option or radio label
- return this.getSelectLabel(value, form, type);
-
case 'repeater':
- return this.formatRepeaterValue(value);
+ return this.getRepeaterValue(element, conf);
+
+ case 'tag-list':
+ return this.getTagListValue(element, conf);
case 'group':
- return this.formatGroupValue(value);
+ return null;
+ //Do we actually need anything here? I think each subfield just
case 'location':
- return this.formatLocationValue(value);
+ return this.getLocationValue(element, conf);
+ case 'selector':
case 'upload':
- return this.formatFileValue(value);
+ case 'gallery':
+ case 'image':
+ return this.getHiddenInputValue(element, conf, fieldName);
- case 'number':
- return this.formatNumber(value);
+ case 'true-false':
+ case 'toggle-text':
+ return element.checked;
+ 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) {
+ const items = element.querySelector('.repeater-items');
+ if (!items) return [];
+ let ignore = ['image_data','image-title','image-caption','image-description','image-alt-text']
+ let value = [];
+ Array.from(items.children).forEach(row => {
+ let rowData = {};
+ row.querySelectorAll('[data-field]').forEach(field => {
+ if (!ignore.includes(field.dataset.field)) {
+ const input = this.getFieldInput(field);
+ if (input) {
+ rowData[field.dataset.field] = this.getFieldValue(input);
+ }
+ }
+ });
+ value.push(rowData);
+ });
+ return value;
+ }
+ getFieldInput(field) {
+ // For quill fields, target the specific editor textarea
+ const quillTextarea = field.querySelector('textarea[data-editor]');
+ if (quillTextarea) return quillTextarea;
+
+ return field.querySelector(this.inputSelectors);
+ }
+ 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 (element.tagName !== 'INPUT' || element.type !== 'hidden'){
+ element = element.querySelector('input[type="hidden"][name="'+fieldName+'"]');
+ if (!element) {
+ return null;
+ }
+ }
+
+ if (conf.value === undefined || conf.value !== element.value) {
+ conf.value = element.value;
+ this.saveItem(conf);
+ }
+ return conf.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':
+ case 'image': //legacy, shouldn't be needed
+ case 'gallery': //legacy, shouldn't be needed
+ // 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 (['upload', 'gallery', 'image'].includes(fieldType)) {
+ // 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;
- /**
- * Event system
- */
+ //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);
+ }
+ 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('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.element.removeEventListener('input', this.countUpdaters);
+ });
+ }
+ this.inputs.clear();
+ this.forms.clear();
+ this.charLimits.clear();
+
}
}
-
document.addEventListener('DOMContentLoaded', async function () {
window.auth.subscribe(event => {
if (event === 'auth-loaded') {
- window.jvbForm = FormController;
+ window.jvbForm = new FormController();
}
});
-
});
--
Gitblit v1.10.0