From 2c955cebb5f1e01fbdb866b50d296fe9fbd852b8 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 06 Jan 2026 20:40:03 +0000
Subject: [PATCH] =TaxonomySelector.js and creator refactor complete
---
assets/js/concise/FormController.js | 924 +++++++++++++++++++++++++++++++++-----------------------
1 files changed, 541 insertions(+), 383 deletions(-)
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 73a2756..a0388e2 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -1,21 +1,26 @@
-/**
- * Enhanced FormController - Manages forms with special fields, caching, and queue integration
- * Works with DataStore for CRUD operations and standalone for front-end forms
- */
class FormController {
- constructor() {
- this.store = new window.jvbStore({
- name:'forms',
- storeName: 'forms',
- keyPath: 'formId',
- indexes: [
- { name: 'status', keyPath: 'status' },
- { name: 'operationId', keyPath: 'operationId' },
- { name: 'timestamp', keyPath: 'timestamp' },
- { name: 'formType', keyPath: 'type' }
- ],
- TTL: 604800000, //7 days
- });
+ constructor(config = {}) {
+ this.config = {
+ collectFormData: false,
+ ... config
+ }
+ this.isRestoring = false;
+ const store = window.jvbStore.register(
+ 'forms',
+ {
+ storeName: 'forms',
+ keyPath: 'formId',
+ indexes: [
+ { name: 'status', keyPath: 'status' },
+ { name: 'operationId', keyPath: 'operationId' },
+ { 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;
@@ -50,25 +55,57 @@
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.focusHandler = this.handleFocus.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() {
- // Check for pending operations on page load
- await this.checkPendingOperations();
-
this.store.subscribe(this.handleStoreEvent.bind(this));
// Set up global form handlers for standalone forms
this.initListeners();
+ if (window.jvbQueue) {
+ window.jvbQueue.subscribe((event, data) => {
+ if (event === 'operation-completed' && data.type === 'form') {
+ this.handleOperationComplete(data);
+ }
+ });
+ }
+ }
+
+ /**
+ * 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);
+ }
+ }
+
+ // 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) {
@@ -84,57 +121,98 @@
}
}
+ /**
+ * Check for pending forms from current page
+ */
async checkPendingForms() {
- let items = await this.store.query('status', 'draft');
- items.forEach(item => {
- let form = this.forms.get(item.formId);
- if (form && form.element) {
- form.element.querySelector('.restore-form').hidden = false;
- new this.populateForm(form.element, item.data);
- }
+ 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;
});
+ 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
+ this.isRestoring = true;
+ // Auto-populate the form
+ new this.populateForm(formElement, item.data);
+
+ // 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');
+ }
+ });
}
+
/**
- * Check for pending operations from previous session
+ * Find form element that matches the cached data
*/
- async checkPendingOperations() {
- const pendingForms = await this.store.query('status', 'pending');
+ 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 (pendingForms.length === 0) return;
+ // 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;
+ }
- // Group by form type or page
- const grouped = this.groupPendingForms(pendingForms);
-
- // Show consolidated notification
- this.showPendingNotification(grouped);
+ // Fallback: try by formId (if it was already registered)
+ return document.querySelector(`[data-form-id="${formData.formId}"]`);
}
/**
* Show notification for pending changes
*/
- showPendingNotification(pendingData) {
- const formElement = document.querySelector(`[data-form-id="${pendingData.formId}"]`);
+ /**
+ * 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="${pendingData.formId}">Restore</button>
- <button class="discard-changes" data-form-id="${pendingData.formId}">Discard</button>
- `;
+ <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', () => {
- this.restorePendingForm(pendingData);
+ notification.querySelector('.restore-changes').addEventListener('click', async () => {
+ await this.restorePendingForm(formId, formData);
notification.remove();
});
- notification.querySelector('.discard-changes').addEventListener('click', () => {
- this.discardPendingForm(pendingData.formId);
+ notification.querySelector('.discard-changes').addEventListener('click', async () => {
+ await this.discardPendingForm(formId);
notification.remove();
});
}
@@ -142,16 +220,20 @@
/**
* Restore pending form data
*/
- restorePendingForm(pendingData) {
- const form = document.querySelector(`[data-form-id="${pendingData.formId}"]`);
+ async restorePendingForm(formId, formData) {
+ const form = document.querySelector(`[data-form-id="${formId}"]`);
if (!form) return;
// Populate form with cached data
- new this.populateForm(form, pendingData.formData);
+ new this.populateForm(form, formData);
- // Mark as restored
- pendingData.status = 'restored';
- this.pendingForms.set(pendingData.formId, pendingData);
+ // 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');
@@ -162,10 +244,14 @@
* Discard pending form data
*/
async discardPendingForm(formId) {
- this.store.delete(formId);
+ try {
+ await this.store.delete(formId);
- if (window.jvbA11y) {
- window.jvbA11y.announce('Previous changes discarded');
+ if (window.jvbA11y) {
+ window.jvbA11y.announce('Previous changes discarded');
+ }
+ } catch (error) {
+ console.error('Failed to discard pending form:', error);
}
}
@@ -177,7 +263,6 @@
if (!this.globalHandlersAdded) {
document.addEventListener('click', this.clickHandler);
document.addEventListener('change', this.changeHandler);
- document.addEventListener('focus', this.focusHandler, true);
document.addEventListener('blur', this.blurHandler, true);
document.addEventListener('input', this.inputHandler);
this.globalHandlersAdded = true;
@@ -200,27 +285,25 @@
status: '',
options: {
autosave: 'autosave' in formElement.dataset,
+ autoUpload: true,
saveDelay: this.autoSaveDefaults.delay,
- endpoint: formElement.dataset.save??'',
+ endpoint: formElement.dataset.save ?? '',
formStatus: true,
cache: true,
...options
},
dependencies: new Map(),
- data: this.collectFormData(formElement),
+ data: this.collectFormData(formElement, true),
};
- // Initialize special fields
this.initializeFormFields(formElement, formConfig);
-
- // Store form config
this.forms.set(formId, formConfig);
- // Check for pending data
+ // Check for pending data - FIXED
if (this.store && formConfig.options.cache) {
const cached = this.store.get(formId);
- if (cached && cached.formData) {
- this.showPendingNotification(cached);
+ if (cached && cached.data) {
+ this.showPendingNotification(formId, cached.data);
}
}
@@ -237,6 +320,8 @@
// Initialize repeater fields
this.initRepeaterFields(form, formConfig);
+ this.initTagListFields(form, formConfig);
+
// Initialize conditional fields
if (formConfig) {
this.initConditionalFields(form, formConfig);
@@ -246,7 +331,7 @@
this.initCharacterLimits(form);
// Initialize image upload fields
- this.initImageUploadFields(form);
+ this.initImageUploadFields(form, formConfig);
// Initialize tabs if present
if (window.jvbTabs && form.querySelector('nav.tabs')) {
@@ -527,6 +612,231 @@
}
/**
+ * 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();
+ }
+
+
+ // Schedule save
+ if (formConfig) {
+ this.scheduleSave(formConfig, {
+ type: 'tag_list',
+ action: 'add',
+ fieldName: fieldName,
+ delay: this.autoSaveDefaults.delay
+ });
+ }
+
+ 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();
+ }
+ });
+ });
+ }
+
+ // 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
+ });
+ }
+
+ if (window.jvbA11y) {
+ window.jvbA11y.announce(`${tagText} removed`);
+ }
+ }
+ });
+ });
+ }
+
+ /**
+ * 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}`;
+ });
+ });
+ }
+
+ /**
+ * Get display text for tag based on format
+ */
+ getTagDisplayText(data, format) {
+ const values = Object.values(data).filter(v => v);
+
+ if (values.length === 0) return 'New Item';
+
+ switch (format) {
+ case 'first_field':
+ return values[0];
+
+ 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];
+ }
+ }
+
+ /**
+ * HTML escape helper
+ */
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ /**
* Initialize conditional fields
*/
initConditionalFields(form, formConfig) {
@@ -571,8 +881,8 @@
const requiredStr = String(requiredValue || '');
switch (operator) {
- case '==': return fieldStr == requiredStr;
- case '!=': return fieldStr != requiredStr;
+ case '==': return fieldStr === requiredStr;
+ case '!=': return fieldStr !== requiredStr;
case '>': return parseFloat(fieldStr) > parseFloat(requiredStr);
case '<': return parseFloat(fieldStr) < parseFloat(requiredStr);
case '>=': return parseFloat(fieldStr) >= parseFloat(requiredStr);
@@ -580,7 +890,7 @@
case 'contains': return fieldStr.includes(requiredStr);
case 'empty': return fieldStr === '';
case 'not_empty': return fieldStr !== '';
- default: return fieldStr == requiredStr;
+ default: return fieldStr === requiredStr;
}
}
@@ -645,25 +955,27 @@
/**
* Initialize image upload fields
*/
- initImageUploadFields(form) {
- window.jvbUploads.scanFields(form);
+ initImageUploadFields(form, config) {
+ window.jvbUploads.scanFields(form, config.options.autoUpload);
}
/* ========== Event Handlers ========== */
async handleSubmit(event) {
const form = event.target;
- if (!form.dataset.formId) return;
+ if (!form.dataset.formId) return;
const formConfig = this.forms.get(form.dataset.formId);
// Handle subscriber-based forms
if (this.subscribers.size > 0) {
event.preventDefault();
const formData = this.collectFormData(form);
+
+ // Notify subscribers (they'll handle actual submission)
this.notify('form-submit', {
- formId: formConfig.id,
- data: formData,
+ formId: form.dataset.formId,
+ fullData: formData,
config: formConfig
});
}
@@ -686,7 +998,6 @@
success.textContent = data.message;
form.insertBefore(success, form.firstChild);
- // Optionally add icon
const icon = window.getIcon?.('check-circle');
if (icon) {
icon.classList.add('success-icon');
@@ -706,7 +1017,6 @@
}
if (data.description) {
- // Handle both string and array descriptions
const descriptions = Array.isArray(data.description)
? data.description
: [data.description];
@@ -721,6 +1031,21 @@
form.insertBefore(successBox, form.firstChild);
}
+ // DELETE CACHED FORM DATA ON SUCCESS
+ if (form.dataset.formId) {
+ this.store.delete(form.dataset.formId).catch(err => {
+ console.warn('Failed to clear form cache:', err);
+ });
+
+ // Clear form config dirty state
+ const formConfig = this.forms.get(form.dataset.formId);
+ if (formConfig) {
+ formConfig.isDirty = false;
+ formConfig.lastSaved = Date.now();
+ formConfig.data = {}; // Clear cached data
+ }
+ }
+
// Announce success for accessibility
if (window.jvbA11y) {
window.jvbA11y.announce(data.message || 'Form submitted successfully');
@@ -801,17 +1126,25 @@
let container = window.targetCheck(e, 'div.quantity');
this.handleNumberClick(e, container.querySelector('input'));
} else if (window.targetCheck(e, '[data-action]')) {
- let action = window.targetCheck(e, '[data-action]');
- action = action.dataset.action;
+ let actionEl = window.targetCheck(e, '[data-action]');
+ let action = actionEl.dataset.action;
+ let form = actionEl.closest('form');
+
switch (action) {
case 'clear-form':
- let form = e.target.closest('form');
- this.store.delete(form.dataset.formId);
- form?.reset();
- e.target.closest('.restore-form').hidden = true;
+ 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':
- e.target.closest('.restore-form').hidden = true;
+ form.querySelector('.fstatus').hidden = true;
break;
}
}
@@ -873,7 +1206,7 @@
}
handleChange(event) {
- if (event.target.closest('[data-ignore]')) {
+ if (event.target.closest('[data-ignore]') || this.isRestoring) {
return;
}
const target = event.target;
@@ -881,7 +1214,7 @@
const formConfig = this.forms?.get(form.dataset.formId);
if (!formConfig) return;
- console.log(formConfig.options);
+
if (formConfig.options.autosave || this.subscribers.size > 0) {
// Check conditional fields
const dependencies = formConfig.dependencies.get(target.name);
@@ -897,16 +1230,8 @@
}
}
- handleFocus(event) {
- const target = event.target;
- if (target.matches('input, textarea, select')) {
- // Track focus for better UX
- this.currentFocus = target;
- }
- }
-
handleBlur(e) {
- if (e.target.closest('[data-ignore]')) {
+ if (e.target.closest('[data-ignore]') || this.isRestoring) {
return;
}
const target = e.target;
@@ -942,7 +1267,7 @@
}
handleInput(e) {
- if (e.target.closest('[data-ignore]') || ! e.target.closest('form')) {
+ if (e.target.closest('[data-ignore]') || ! e.target.closest('form') || this.isRestoring) {
return;
}
const input = e.target.closest('input, textarea, select');
@@ -962,7 +1287,7 @@
if (this.shouldDebounce(input)){
window.debouncer.schedule(
`validate_${fieldName}`,
- (input, fieldWrapper) => this.validateField.bind(this),
+ () => this.validateField.bind(this),
500
)
}
@@ -982,10 +1307,10 @@
},
url: {
pattern: /^https?:\/\/.+\..+/,
- message: 'Please enter a valid URL starting with http:// or https://'
+ message: 'Please enter a valid URL starting with https://'
},
phone: {
- pattern: /^[\d\s\-\+\(\)\.]+$/,
+ pattern: /^[\d\s\-+().]+$/,
message: 'Please enter a valid phone number'
},
number: {
@@ -1110,23 +1435,7 @@
return true;
}
- /**
- * Get field value (handles different input types)
- */
- getFieldValue(input) {
- if (!input) return '';
- if (input.type === 'checkbox') {
- return input.checked ? input.value || '1' : '';
- } else if (input.type === 'radio') {
- const checked = input.form?.querySelector(`[name="${input.name}"]:checked`);
- return checked ? checked.value : '';
- } else if (input.type === 'select-multiple') {
- return Array.from(input.selectedOptions).map(o => o.value);
- }
-
- return input.value?.trim() || '';
- }
/**
* Show success state (green checkmark)
@@ -1224,145 +1533,6 @@
}
}
- /**
- * Validate all fields in a container (useful for step validation)
- */
- validateAllFields(container) {
- if (!container) return true;
-
- const fields = container.querySelectorAll('.field:not([hidden])');
- let allValid = true;
-
- fields.forEach(fieldWrapper => {
- // Skip complex parent wrappers (repeater, group) - validate their children
- if (this.isComplexFieldWrapper(fieldWrapper)) {
- return;
- }
-
- const input = fieldWrapper.querySelector('input:not([type="hidden"]), textarea, select');
- if (input && !input.closest('[hidden]')) {
- // Mark as touched so validation will run
- const fieldName = fieldWrapper.dataset.field;
- if (fieldName) {
- this.touchedFields.add(fieldName);
- }
-
- const isValid = this.validateField(input, fieldWrapper);
- if (!isValid) {
- allValid = false;
-
- // Scroll to first error
- if (allValid === false) {
- input.scrollIntoView({ behavior: 'smooth', block: 'center' });
- input.focus();
- }
- }
- }
- });
-
- return allValid;
- }
-
- /**
- * Check if field wrapper is a complex type (repeater, group, etc.)
- */
- isComplexFieldWrapper(fieldWrapper) {
- return fieldWrapper.classList.contains('repeater') ||
- fieldWrapper.classList.contains('group') ||
- fieldWrapper.classList.contains('upload');
- }
-
- /**
- * Special validation for repeater fields
- */
- attachRepeaterValidation(form) {
- // When a repeater row is added, attach validation to its fields
- form.addEventListener('click', (e) => {
- if (e.target.closest('.add-repeater-row')) {
- // Wait for the DOM to update
- setTimeout(() => {
- const repeaterRows = form.querySelectorAll('.repeater-row');
- repeaterRows.forEach(row => {
- const inputs = row.querySelectorAll('input, textarea, select');
- inputs.forEach(input => {
- const fieldWrapper = this.findFieldWrapper(input);
- if (fieldWrapper) {
- // Validation listeners are already attached via event delegation
- // Just clear any existing validation state for new rows
- this.clearValidation(fieldWrapper);
- }
- });
- });
- }, 100);
- }
- });
- }
-
- /**
- * Special validation for group fields
- */
- attachGroupValidation(form) {
- // Group fields might have conditional fields
- // Validate when conditions change
- form.addEventListener('change', (e) => {
- const changedInput = e.target.closest('input, select');
- if (!changedInput) return;
-
- // Check if this change affects conditional fields
- const fieldName = changedInput.name;
- if (!fieldName) return;
-
- // Find any conditional fields that depend on this field
- const conditionalFields = form.querySelectorAll(`[data-show-if*="${fieldName}"]`);
- conditionalFields.forEach(conditionalField => {
- // Clear validation for hidden fields
- if (conditionalField.hidden) {
- this.clearValidation(conditionalField);
- }
- });
- });
- }
-
- /**
- * Reset validation state for a form
- */
- resetForm(form) {
- if (!form) return;
-
- // Clear all touched fields
- this.touchedFields.clear();
-
- // Clear all validation states
- const fields = form.querySelectorAll('.field');
- fields.forEach(fieldWrapper => {
- this.clearValidation(fieldWrapper);
- });
- }
-
- /**
- * Get validation errors for a form
- */
- getFormErrors(form) {
- const errors = {};
- const fields = form.querySelectorAll('.field.has-error');
-
- fields.forEach(fieldWrapper => {
- const fieldName = fieldWrapper.dataset.field;
- const message = fieldWrapper.querySelector('.validation-message');
- if (fieldName && message) {
- errors[fieldName] = message.textContent;
- }
- });
-
- return errors;
- }
-
- /**
- * Add custom validator
- */
- addValidator(name, validator) {
- this.validators[name] = validator;
- }
/* ========== Auto-save functionality ========== */
/**
* Get appropriate delay based on field type and context
@@ -1409,33 +1579,40 @@
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(()=> {
+ }).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);
- console.log('Changes:', changes);
if (Object.keys(changes).length === 0) return;
- console.log('Continuing on:');
// Update stored data
formConfig.data = formData;
this.forms.set(formConfig.id, formConfig);
document.removeEventListener('input', this.handleInput);
- for (let [key, value] of Object.entries(formData)) {
- //We want all data for complex fields, like group, repeater, or location
+ for (let [key, value] of Object.entries(formData)) {
+ // Complex fields need full data
if (typeof value === 'object') {
changes[key] = value;
}
}
- // Notify instead of callback
+
+ // Notify
this.notify('form-autosave', {
formId: formConfig.id,
changes: changes,
@@ -1462,9 +1639,8 @@
}
showFormStatus(formID, status, message='') {
- // Remove existing status
let form = this.forms.get(formID);
- if (!form.options.formStatus) {
+ if (!form?.options.formStatus) {
return;
}
@@ -1474,14 +1650,12 @@
form.status = status;
- console.log('Setting status: ', status);
-
- // Add new status
const statusWrap = form.element.querySelector('.fstatus');
statusWrap.hidden = false;
const statusElement = statusWrap.querySelector('.message');
statusElement.textContent = '';
statusWrap.querySelector('.icon')?.remove();
+ statusWrap.querySelector('.actions')?.remove(); // Clear old actions
const messages = {
'saving': 'Saving changes...',
@@ -1489,12 +1663,15 @@
'uploading': 'Uploading your form to server',
'submitted': 'Successfully sent to server',
'pending': 'Unsaved changes',
+ 'restored': 'Welcome back! We\'ve restored your previous entry.',
'error': 'Failed to save changes. Refresh and try again?',
'offline': 'Changes will be saved when online'
};
+
const icons = {
'autosaved': 'check-circle',
'submitted': 'check-circle',
+ 'restored': 'history',
'error': 'close-circle',
'offline': 'cloud-slash',
'pending': 'exclamation-mark'
@@ -1504,12 +1681,27 @@
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);
@@ -1538,6 +1730,11 @@
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 = {};
@@ -1549,7 +1746,7 @@
const processor = this.getFieldProcessor(key);
processor(key, value, data, repeaterData, postData, form);
}
- if (!window.isEmptyObject(postData)) {
+ if (Object.keys(postData).length !== 0) {
data = this.mergeRepeaterData(data, repeaterData);
return this.mergePostData(data, postData);
}
@@ -1557,7 +1754,6 @@
}
collectTimeline(form) {
- console.log('Collecting Timeline data:');
let data = {};
let posts = {}; // Temporary object keyed by post ID
let postOrder = []; // Track order as encountered (preserves DOM/drag order)
@@ -1567,16 +1763,23 @@
if (this.ignore.includes(key) || key.endsWith('_temp')) {
continue;
}
- const match = key.match(/^\[(\d+)\](.+)$/);
+ const match = key.match(/^\[(\d+)](.+)$/);
if (match) {
// Timeline-specific field: [postId]fieldName
const [, postId, fieldName] = match;
if (!posts[postId]) {
- posts[postId] = { id: parseInt(postId) };
+ posts[postId] = {
+ id: parseInt(postId),
+ };
postOrder.push(postId); // Track first occurrence
}
- const processor = this.getFieldProcessor(fieldName);
- processor(fieldName, value, posts[postId], {}, {}, form);
+ 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);
@@ -1592,15 +1795,13 @@
delete data['timeline_temp'];
delete data['']; // Empty key
- console.log('Data: ', data);
return data;
}
getFieldProcessor(key) {
- if (key.includes('|')) return this.processTableField;
if (key.includes('::')) return this.processGroupField;
if (key.includes(':')) return this.processRepeaterField;
- if (/\[[^\]]+\]/.test(key)) return this.processLocationField;
+ if (/\[[^\]]+]/.test(key)) return this.processLocationField;
return this.processRegularField;
}
@@ -1622,42 +1823,12 @@
}
mergePostData(data, postData) {
- for (let [postId, postData] in Object.entries(postData)) {
- data[postId] = postData;
+ for (let [postId, fields] of Object.entries(postData)) {
+ data[postId] = fields;
}
return data;
}
- processTableField(key, value, data, repeaterData, postData, form) {
- /***
- * Table forms are a huge form containing multiple posts and their data
- * Field names are prepended with `${postID}|`
- * Goal:
- * 1) Separate out the post id from the field name
- * 2) store the original data in a temporary 'original' variable
- * 3) Process the field as normal
- * 4) return the original data, as PostID: {$field data}
- * Final format:
- * {
- * id1: {
- * field1: "A title",
- * field3: 32
- * },
- * id2: {
- * field1: "Another title",
- * field2: "122,21,32"
- * }
- * }
- **/
- let [post, fieldKey] = key.split('|');
- if (!post in postData) {
- postData[post] = {};
- }
-
- const processor = this.getFieldProcessor(fieldKey);
- processor(fieldKey, value, postData, repeaterData, postData, form);
-
- }
processRepeaterField(key, value, data, repeaterData, postData, form) {
let [fieldName, index, subField] = key.split(':');
@@ -1745,19 +1916,22 @@
}
}
- getFieldValue(field) {
- if (!field) return '';
+ /**
+ * Get field value (handles different input types)
+ */
+ getFieldValue(input) {
+ if (!input) return '';
- if (field.type === 'checkbox') {
- return field.checked ? field.value || '1' : '';
- } else if (field.type === 'radio') {
- const checked = field.form.querySelector(`[name="${field.name}"]:checked`);
+ 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 (field.type === 'select-multiple') {
- return Array.from(field.selectedOptions).map(o => o.value);
- } else {
- return field.value;
+ } else if (input.type === 'select-multiple') {
+ return Array.from(input.selectedOptions).map(o => o.value);
}
+
+ return input.value?.trim() || '';
}
getChangedFields(original, current) {
@@ -1776,16 +1950,8 @@
const form = formConfig.element || document.querySelector(`[data-form-id="${formId}"]`);
const summary = window.getTemplate('formSummary');
-
- const [
- title,
- resultWrapper,
- resultTemplate
- ] = [
- summary.querySelector('h2'),
- summary.querySelector('.summary'),
- summary.querySelector('.result')
- ];
+ if (!summary) return;
+ const wrapper = summary.querySelector('.result');
// Fields to skip in summary
const skipFields = ['sendAll', ...this.ignore];
@@ -1799,23 +1965,49 @@
// Get field info from form
const fieldInfo = this.getFieldInfo(form, key);
+
if (!fieldInfo.label) continue; // Skip if no label found
- // Create result element
- const resultEl = this.createResultElement(
- resultTemplate,
- fieldInfo,
- value,
- form
- );
+ let field = wrapper.cloneNode(true);
+ let title = field.querySelector('h3');
+ let p = field.querySelector('p');
- if (resultEl) {
- resultWrapper.appendChild(resultEl);
+ 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
- resultTemplate.remove();
+ wrapper.remove();
// Insert summary and hide form
clear = (clear !== 'form') ? form.closest(clear)??form : form;
@@ -1834,10 +2026,8 @@
if (Array.isArray(value) && value.length === 0) {
return true;
}
- if (typeof value === 'object' && Object.keys(value).length === 0) {
- return true;
- }
- return false;
+ return typeof value === 'object' && Object.keys(value).length === 0;
+
}
/**
@@ -1847,9 +2037,7 @@
getFieldInfo(form, fieldName) {
// Try to find label by 'for' attribute (exact match)
let label = form.querySelector(`label[for="${fieldName}"]`);
- let input = null;
- let fieldWrapper = null;
-
+ let input = form.querySelector(`[name=${fieldName}]`);
// Try to find the input field - check multiple patterns
if (!input) {
// Try exact match first
@@ -1875,12 +2063,12 @@
// Try closest field wrapper first
const field = input.closest('.field, fieldset');
if (field) {
- label = field.querySelector('label, legend');
+ label = field.querySelector('label, legend, h2');
}
}
// Get field wrapper - always use base name (no special characters)
- fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`);
+ let fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"], fieldset[data-field="${fieldName}"]`);
// Determine field type
let fieldType = 'text';
@@ -1908,32 +2096,6 @@
}
/**
- * Create a result element for a field
- */
- createResultElement(template, fieldInfo, value, form) {
- const resultEl = template.cloneNode(true);
- const titleEl = resultEl.querySelector('h4');
- const valueEl = resultEl.querySelector('p');
-
- // Set label
- titleEl.textContent = fieldInfo.label;
-
- // Format value based on field type
- const formattedValue = this.formatFieldValue(value, fieldInfo.type, form);
-
- // Determine how to set the value
- if (this.isHtmlContent(formattedValue)) {
- // HTML content - use innerHTML
- valueEl.innerHTML = formattedValue;
- } else {
- // Plain text - use textContent for safety
- valueEl.textContent = formattedValue;
- }
-
- return resultEl;
- }
-
- /**
* Check if content should be treated as HTML
*/
isHtmlContent(content) {
@@ -1966,7 +2128,7 @@
if (Array.isArray(value)) {
return this.formatArrayValue(value);
}
- return (value === '1' || value === 1 || value === true) ? 'Yes' : 'No';
+ return (value === '1' || value === 1 || value === true) ? 'Yes' : value;
case 'select':
// Handle both single and multi-select
@@ -1993,8 +2155,7 @@
case 'location':
return this.formatLocationValue(value);
- case 'file':
- case 'image':
+ case 'upload':
return this.formatFileValue(value);
case 'number':
@@ -2237,13 +2398,6 @@
}
/**
- * Convert newlines to <br> tags (kept for backwards compatibility)
- */
- nl2br(text) {
- return this.formatPlainText(text);
- }
-
- /**
* Event system
*/
subscribe(callback) {
@@ -2281,7 +2435,6 @@
// Remove global handlers
if (this.globalHandlersAdded) {
document.removeEventListener('change', this.changeHandler);
- document.removeEventListener('focus', this.focusHandler, true);
document.removeEventListener('blur', this.blurHandler, true);
document.removeEventListener('input', this.inputHandler, true);
}
@@ -2303,6 +2456,11 @@
}
}
-document.addEventListener('DOMContentLoaded', () => {
- window.jvbForm = FormController;
+document.addEventListener('DOMContentLoaded', async function () {
+ window.auth.subscribe(event => {
+ if (event === 'auth-loaded') {
+ window.jvbForm = FormController;
+ }
+ });
+
});
--
Gitblit v1.10.0