| | |
| | | * 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 |
| | | } |
| | | 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; |
| | | |
| | |
| | | remove: 800, |
| | | reorder: 1000 |
| | | }; |
| | | this.isTimeline = false; |
| | | if (window.crudManager && window.crudManager.isTimeline) { |
| | | this.isTimeline = true; |
| | | } |
| | | |
| | | // Bind handlers |
| | | this.clickHandler = this.handleClick.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) { |
| | |
| | | } |
| | | } |
| | | |
| | | 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; |
| | | checkPendingForms() { |
| | | // No async needed - data is already loaded in memory |
| | | const allForms = this.store.getAll(); |
| | | const pendingForms = allForms.filter(form => form.status === 'draft'); |
| | | |
| | | pendingForms.forEach(item => { |
| | | const form = this.forms.get(item.formId); |
| | | if (form?.element) { |
| | | const restoreBtn = form.element.querySelector('.restore-form'); |
| | | if (restoreBtn) { |
| | | restoreBtn.hidden = false; |
| | | } |
| | | new this.populateForm(form.element, item.data); |
| | | } |
| | | }); |
| | | |
| | | } |
| | | /** |
| | | * Check for pending operations from previous session |
| | |
| | | /** |
| | | * 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(); |
| | | }); |
| | | } |
| | |
| | | /** |
| | | * 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'); |
| | |
| | | * 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); |
| | | } |
| | | } |
| | | |
| | |
| | | ...options |
| | | }, |
| | | dependencies: new Map(), |
| | | data: this.collectFormData(formElement), |
| | | data: this.collectFormData(formElement, true), |
| | | }; |
| | | |
| | | // Initialize special fields |
| | |
| | | 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 |
| | | }); |
| | | |
| | | // Don't delete yet - wait for success/error from subscriber |
| | | return; |
| | | } |
| | | |
| | | // For forms that submit normally (not prevented) |
| | | // We can clean up the cache on successful submission |
| | | // This would typically be called from handleFormSuccess |
| | | } |
| | | |
| | | handleFormSuccess(form, data) { |
| | |
| | | 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'); |
| | |
| | | } |
| | | |
| | | if (data.description) { |
| | | // Handle both string and array descriptions |
| | | const descriptions = Array.isArray(data.description) |
| | | ? data.description |
| | | : [data.description]; |
| | |
| | | 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'); |
| | |
| | | |
| | | 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); |
| | |
| | | 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, |
| | |
| | | |
| | | form.status = status; |
| | | |
| | | console.log('Setting status: ', status); |
| | | |
| | | // Add new status |
| | | const statusWrap = form.element.querySelector('.fstatus'); |
| | | statusWrap.hidden = false; |
| | |
| | | |
| | | /* ========== Form Data Methods ========== */ |
| | | |
| | | collectFormData(form) { |
| | | collectFormData(form, isInit = false) { |
| | | 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 = {}; |
| | |
| | | } |
| | | |
| | | 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) |
| | |
| | | // 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); |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | 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(':'); |
| | | |