From d7dbe7fee362d587dfc334135d9581b6216a4295 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 23 Nov 2025 04:13:56 +0000
Subject: [PATCH] =Timeline block, and feed block updated. DataStore.js refactored to not block rendering
---
assets/js/concise/FormController.js | 254 +++++++++++++++++++++++++++++++-------------------
1 files changed, 159 insertions(+), 95 deletions(-)
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 73a2756..80be7b3 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -3,19 +3,26 @@
* 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.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.debouncer = window.debouncer;
@@ -49,6 +56,10 @@
remove: 800,
reorder: 1000
};
+ this.isTimeline = false;
+ if (window.crudManager && window.crudManager.isTimeline) {
+ this.isTimeline = true;
+ }
// Bind handlers
this.clickHandler = this.handleClick.bind(this);
@@ -57,18 +68,49 @@
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,16 +126,21 @@
}
}
- 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
@@ -113,28 +160,31 @@
/**
* 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 +192,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 +216,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);
}
}
@@ -207,7 +265,7 @@
...options
},
dependencies: new Map(),
- data: this.collectFormData(formElement),
+ data: this.collectFormData(formElement, true),
};
// Initialize special fields
@@ -661,12 +719,21 @@
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) {
@@ -686,7 +753,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 +772,6 @@
}
if (data.description) {
- // Handle both string and array descriptions
const descriptions = Array.isArray(data.description)
? data.description
: [data.description];
@@ -721,6 +786,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');
@@ -881,7 +961,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);
@@ -1409,33 +1489,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,
@@ -1474,8 +1561,6 @@
form.status = status;
- console.log('Setting status: ', status);
-
// Add new status
const statusWrap = form.element.querySelector('.fstatus');
statusWrap.hidden = false;
@@ -1534,10 +1619,15 @@
/* ========== 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 = {};
@@ -1557,7 +1647,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)
@@ -1572,11 +1661,18 @@
// 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,12 +1688,10 @@
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;
@@ -1622,42 +1716,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(':');
--
Gitblit v1.10.0