From 457c329237f97069063e641b10f384a52d584f21 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 12 May 2026 17:50:11 +0000
Subject: [PATCH] =minor tweaks

---
 assets/js/concise/CRUD.js |  477 +++++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 files changed, 412 insertions(+), 65 deletions(-)

diff --git a/assets/js/concise/CRUD.js b/assets/js/concise/CRUD.js
index 6eee959..3255643 100644
--- a/assets/js/concise/CRUD.js
+++ b/assets/js/concise/CRUD.js
@@ -42,8 +42,8 @@
 
 		const baseSetup = (el, refs, data) => {
 			el.dataset.itemId = data.id;
-
-			window.prefixInput(refs.checkbox, `select-${data.id}`, true);
+			let wrapper = refs.checkbox.closest('.preview');
+			window.prefixInput(refs.checkbox, `select-${data.id}`, wrapper, true);
 			refs.checkbox.value = data.id;
 			refs.checkbox.checked = crud.selected.has(parseInt(data.id));
 			if (refs.selectLabel) refs.selectLabel.htmlFor = `select-${data.id}`;
@@ -52,8 +52,9 @@
 			if (refs.trash) refs.trash.dataset.id = data.id;
 		};
 		const imageSetup = function(el, refs, data) {
-			if (data?.fields?.post_thumbnail) {
-				const thumbnail = data.images[data.fields.post_thumbnail] ?? {};
+			let hasThumbnail = data?.fields?.post_thumbnail || data?.fields?.thumbnail;
+			if (hasThumbnail) {
+				const thumbnail = data.images[hasThumbnail] ?? {};
 				refs.img.src = thumbnail.medium??'';
 				refs.img.alt = thumbnail.alt??data.fields.post_title??'';
 			}
@@ -131,7 +132,8 @@
 				baseSetup(el, refs, data);
 
 				manyRefs?.inputs?.forEach(el => {
-					window.prefixInput(el, `${data.id}-`);
+					let wrapper = el.closest('[data-field]');
+					window.prefixInput(el, `${data.id}-`, wrapper);
 				});
 
 				manyRefs?.status?.forEach(el => {
@@ -143,7 +145,8 @@
 				if (crud.isTimeline) {
 					if (refs.sharedRow) {
 						refs.sharedRow.querySelectorAll('input,select,textarea').forEach(input => {
-							window.prefixInput(input, `${data.id}-`);
+							let wrapper = input.closest('[data-field]');
+							window.prefixInput(input, `${data.id}-`, wrapper);
 						});
 
 						crud.populate.populate(refs.sharedRow, data);
@@ -164,7 +167,8 @@
 							point.dataset.itemId = timeline.id;
 
 							point.querySelectorAll('input,select,textarea').forEach(input => {
-								window.prefixInput(input, `${timeline.id}-`);
+								let wrapper = input.closest('[data-field]');
+								window.prefixInput(input, `${timeline.id}-`, wrapper);
 							});
 
 							crud.populate.populate(point, {
@@ -185,7 +189,8 @@
 					if (crud.ui.table.form?.dataset.edit !== undefined) {
 						// Non-timeline: prefix all inputs normally
 						manyRefs?.inputs?.forEach(input => {
-							window.prefixInput(input, `${data.id}-`);
+							let wrapper = input.closest('[data-field]');
+							window.prefixInput(input, `${data.id}-`, wrapper);
 						});
 
 						manyRefs?.status?.forEach(el => {
@@ -319,7 +324,11 @@
 				},
 				date: '[data-filter="date"]'
 			},
-			uploader: 'details.uploader'
+			uploader: {
+				details: 'details.uploader',
+				form: 'details.uploader form',
+				uploader: 'details.uploader [data-field-type="upload"]'
+			}
 		}
 
 		this.ui = window.uiFromSelectors(this.selectors);
@@ -335,17 +344,32 @@
 		this.isTimeline = !!document.querySelector('[data-timeline]');
 	}
 		initUploader() {
-			if (!this.ui.uploader) return;
+			if (!this.ui.uploader.form) return;
+			this.uploadForm = this.forms.registerForm(this.ui.uploader.form).id??false;
 
-			window.jvbUploads.scanFields(this.ui.uploader);
+			// window.jvbUploads.scanFields(this.ui.uploader);
 			window.jvbUploads.subscribe((event, data) => {
 				if (event === 'sent-to-queue') {
-					if (data === this.ui.uploader.dataset.uploader) {
+					if (data.field.id === this.ui.uploader.uploader.dataset.uploader) {
+						if (this.uploadForm ) {
+							this.forms.store.delete(this.uploadForm);
+						}
+
 						window.debouncer.schedule('crud-complete', ()=> {
 							this.store.clearCache();
 						});
 					}
 				}
+
+				if (event === 'sent-to-queue' && data.field) {
+					const fieldName = data.field.config.name;
+					const itemId = data.field.config.itemID;
+					if (itemId && fieldName) {
+						if (this.changes.has(itemId)) {
+							delete this.changes.get(itemId)[fieldName];
+						}
+					}
+				}
 			});
 		}
 		initModals() {
@@ -367,6 +391,12 @@
 							if (name === 'date') {
 								this.handleCustomDateSelection()
 							}
+							if (['edit','bulkEdit','create'].includes(name)) {
+								//handle escapes (not form submits)
+								if (window.debouncer.timeouts.has(`save-${this.content}`)) {
+									this.scheduleSave(0);
+								}
+							}
 							break;
 						case 'modal-open':
 
@@ -392,7 +422,7 @@
 					keyPath: 'id',
 					endpoint: this.endpoint??'content',	//for taxonomy stores
 					headers: {
-						'action_nonce': window.auth.getNonce('dash'),
+						'X-Action-Nonce': window.auth.getNonce('dash'),
 					},
 					indexes: [
 						{name: 'id', keyPath: 'id'},
@@ -401,6 +431,7 @@
 						{ name: 'modified', keyPath: 'modified'},
 						{ name: 'title', keyPath: 'title'},
 					],
+					isAuth: true,
 					filters: filters,
 					ignore: ['content', 'user'],
 					TTL: 60 * 60 * 1000, 		//1 hour cache
@@ -472,49 +503,106 @@
 		// 	}
 		// });
 
+		if (window.jvbUploads) {
+			window.jvbUploads.subscribe((event, data) => {
+				if (event === 'groups_uploaded' && data.content === this.content) {
+					this.handleGroupsUploaded(data);
+				}
+			});
+		}
+
 		this.queue.subscribe((event, data) => {
 			if (['image_upload', 'video_upload', 'document_upload'].includes(data.type)
 				&& event === 'operation-status'
 				&& data.status === 'completed') {
 				this.store.clearCache();
 			}
+
+
 			if (event === 'operation-status'
 				&& data.status === 'completed'
-				&& data.endpoint === 'content'
-				&& Object.keys(data.data?.posts??{}).length > 0) {
+				&& data.endpoint === 'uploads/groups') {
+				if (data.result && data.result.group_mappings) {
+					console.log('Handling group mapping from queue response');
+					this.handleGroupMappings(data.result.group_mappings);
+				}
 
 				this.store.clearCache();
-				let ids = Object.keys(data.data.posts);
-				let storedChanges = this.changesStore.getMany(ids);
+			}
 
-				this.changesStore.deleteMany(ids);
+			if (event === 'operation-status'
+				&& data.status === 'completed'
+				&& data.type === 'content_update') {
 
-				for (let id of ids) {
-					let stored = storedChanges.filter(change => change.id === id)[0]??false;
+				this.store.clearCache();
 
-					let sentChanges = data.data.posts[id];
-					let remainingChanges = {};
-
-					for (let [key, value] of Object.entries(sentChanges)) {
-						if (stored && !Object.hasOwn(stored, key)) continue;
-						if (stored[key] === value) {
-							delete stored[key];
-						}
-						remainingChanges[key] = value;
-					}
-					if (Object.keys(remainingChanges).length > 0) {
-						remainingChanges['id'] = id;
-						remainingChanges['content'] = this.content;
-						this.changes.set(id, remainingChanges);
-					}
+				if (!data.result || !data.result.success || !data.result.errors)
+				{
+					console.warn('Content update completed but no results', data);
+					return;
 				}
-				if (Object.values(this.changes).length > 0) {
-					this.scheduleBackup();
+
+				if (Object.keys(data.result.success).length > 0) {
+					this.checkCompletedChanges(Object.entries(data.result.success));
+				}
+				if (Object.keys(data.result.errors).length > 0) {
+					this.checkFailedChanges(Object.entries(data.result.errors));
+					return;
+				}
+
+				if (Object.keys(data.result.success).length === 0) {
+					console.log(data.result.success);
+					data.result.success.forEach(id => this.changesStore.delete(id));
+
+					this.store.clearCache();
+				}
+			}
+
+			if (event === 'sent-to-server') {
+				if (data instanceof FormData) return;
+
+				for ( let [id, changes] of Object.entries(data.posts)) {
+					this.compareStored(id, changes);
 				}
 			}
 
 		});
 	}
+	checkCompletedChanges(items) {
+		for (let [id, data] of items) {
+			this.compareStored(id, data);
+		}
+	}
+		compareStored(id, data) {
+			let stored = this.changesStore.get(id);
+			if (!stored) return;
+
+			for (let [field, value] of Object.entries(data)) {
+				if (Object.hasOwn(stored, field)) {
+					let changes = window.getDifferences.map(stored[field], value);
+					if (!changes) {
+						delete stored[field];
+					} else {
+						stored[field] = changes;
+					}
+				}
+			}
+
+			let hasID = Object.hasOwn(stored, 'id');
+			let hasContent = Object.hasOwn(stored, 'content');
+			if ((hasID && hasContent && Object.keys(stored).length === 2)
+				|| ((hasID || hasContent) && Object.keys(stored).length === 1)
+				|| Object.keys(stored).length === 0
+			) {
+				this.changesStore.delete(id);
+				this.store.clearCache();
+			} else {
+				this.changesStore.save(stored);
+			}
+		}
+	checkFailedChanges(items) {
+		//TODO do something.
+	}
 
 	initSettings() {
 		this.defaults = {
@@ -577,7 +665,7 @@
 				default: 'closed',
 			},
 			showUploader: {
-				element: this.ui.uploader,
+				element: this.ui.uploader.details,
 				default: 'open'
 			}
 		};
@@ -620,15 +708,62 @@
 		const form = e.target;
 		const modal = form.closest('dialog');
 		if (!modal) return;
-		let title = `Saving changes for multiple ${this.plural}`;
-		if (modal.classList.contains('edit')) {
-			title = 'Saving your edits...';
-		} else if (modal.classList.contains('create')) {
-			title = `Creating your new ${this.singular}`;
+
+		if (modal.classList.contains('create')) {
+			this.handleCreateSubmit(modal);
+			return;
 		}
-		this.cancelBackup();
-		this.handleBackup().then(()=>{});
-		this.savePosts(title,false).then(()=>{});
+
+		let title = `Saving changes for multiple ${this.plural}`;
+
+		this.scheduleSave(0);
+		this.modals.edit.handleClose();
+	}
+
+	async handleCreateSubmit(modal) {
+		const itemId = modal.dataset.itemId;
+
+		// 1. Flush changes to store
+		if (this.changes.size > 0) {
+			this.cancelBackup();
+			await this.handleBackup();
+		}
+
+		const changes = await this.changesStore.getAll();
+		if (changes.length === 0) return;
+
+		let allChanges = {};
+		changes.forEach(change => {
+			const { id, ...rest } = change;
+			allChanges[id] = rest;
+		});
+
+		// 2. Queue content creation, get operationId
+		let contentOpId = this.queue.addToQueue({
+			endpoint: this.endpoint,
+			headers: {
+				'X-Action-Nonce': window.auth.getNonce('dash'),
+			},
+			data: {
+				posts: allChanges,
+			},
+			popup: `Creating your new ${this.singular}`,
+			title: `Creating your new ${this.singular}`,
+		});
+
+		if (!contentOpId) return;
+
+		// 3. Queue any pending uploads with dependency on content creation
+		const uploadFields = modal.querySelectorAll('[data-upload-field]');
+		for (const fieldEl of uploadFields) {
+			const fieldId = fieldEl.dataset.uploader;
+			if (!fieldId) continue;
+
+			const uploads = window.jvbUploads.stores.uploads.filterByIndex({ field: fieldId });
+			if (uploads.length === 0) continue;
+
+			await window.jvbUploads.queueUploads('uploads', fieldId, contentOpId);
+		}
 	}
 	handleChange(e) {
 		// Early bailout - target must be in an item or be a filter
@@ -727,23 +862,61 @@
 
 	handleItemUpdate(e) {
 		let item = window.targetCheck(e, '[data-item-id]');
-
 		if (!item) return;
+
+		// Check if inside a collection field first
+		const collection = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]');
+
+		let name, value;
+		if (collection) {
+			name = collection.dataset.field;
+			value = this.forms.getFieldValue(collection);
+		} else {
+			let field = e.target.closest('[data-field]');
+			name = field.dataset.field;
+			value = this.forms.getFieldValue(e.target);
+		}
+
 		item.dataset.itemId.split(',').forEach(itemId => {
-			let field = this.forms.getField(e.target);
-			let name = field.dataset.field;
-			let value = this.forms.getFieldValue(e.target);
 			this.updateItem(itemId, name, value);
 		});
-		this.savePosts('', true).then(()=>{});
 	}
 	updateItem(itemId, name, value) {
+		if (this.isPopulating) {
+			return;
+		}
+		name.replace(`[${itemId}]`, '');
+
+		const stored = this.store.get(itemId);
+		if (stored) {
+			const storedValue = stored.fields?.[name] ?? stored[name];
+			const diff = window.getDifferences.map(storedValue, value);
+
+			if (diff === null) {
+				// Value matches stored — clean up any pending change for this field
+				if (this.changes.has(itemId)) {
+					delete this.changes.get(itemId)[name];
+					// If no real changes left, remove the item entirely
+					const remaining = Object.keys(this.changes.get(itemId))
+						.filter(k => k !== 'id' && k !== 'content');
+					if (remaining.length === 0) {
+						this.changes.delete(itemId);
+						this.changesStore.delete(itemId);
+					}
+				}
+				return;
+			}
+		}
+
 		if (!this.changes.has(itemId)) {
 			this.changes.set(itemId, { id: itemId, content: this.content });
 		}
 		this.changes.get(itemId)[name] = value;
 
 		this.scheduleBackup();
+		if (typeof itemId === 'number' || !String(itemId).includes('group')) {
+			this.scheduleSave();
+		}
 	}
 	scheduleBackup() {
 		window.debouncer.schedule(
@@ -756,13 +929,39 @@
 			2000
 		);
 	}
-
 	cancelBackup() {
 		window.debouncer.cancel(`changes-${this.content}`);
 	}
 	async handleBackup() {
-		await this.changesStore.saveMany(this.changes);
+		const changesArray = Array.from(this.changes.values());
 		this.changes.clear();
+
+		const ids = changesArray.map(c => c.id);
+		const existing = await Promise.all(
+			ids.map(id => this.changesStore.get(id))
+		);
+
+		const changes = changesArray.map((change, i) =>
+			existing[i] ? window.deepMerge(existing[i], change) : change
+		);
+
+		await this.changesStore.saveMany(changes);
+	}
+
+	scheduleSave(delay = 10000) {
+		window.debouncer.schedule(
+			`save-${this.content}`,
+			async () => {
+				// Ensure latest changes are in IndexedDB
+				if (this.changes.size > 0) {
+					this.cancelBackup();
+					await this.handleBackup();
+				}
+
+				await this.savePosts('', false);
+			},
+			delay
+		);
 	}
 	handleFilterChange(target) {
 		let filter = target.dataset.filter;
@@ -868,7 +1067,7 @@
 			return;
 		}
 
-		if (e.target.matches(this.selectors.buttons.create)) {
+		if (e.target.matches(this.selectors.buttons.create) || e.target.closest(this.selectors.buttons.create)) {
 			this.openCreateModal();
 		}
 	}
@@ -876,8 +1075,8 @@
 			this.forms.registerForm(this.ui.modals.create.form,{
 				cache: false,
 			});
-
 			this.ui.modals.create.modal.dataset.itemId = window.generateID('new');
+
 			this.modals.create.handleOpen();
 		}
 		handleActionButton(button) {
@@ -1085,16 +1284,30 @@
 		this.activeItem = item.id;
 		this.ui.modals.edit.modal.dataset.itemId = itemID;
 		this.ui.modals.edit.modal.dataset.content = this.content;
-		this.ui.modals.edit.h2.textContent = `Editing ${item.fields.post_title === '' ? this.singular : item.fields.post_title}`;
+		let title;
+		if (Object.hasOwn(item.fields, 'post_title')) {
+			title = item.fields.post_title;
+		} else if (Object.hasOwn(item.fields, 'name')) {
+			title = item.fields.name;
+		}
+		this.ui.modals.edit.h2.textContent = `Editing ${title === '' ? this.singular : title}`;
 		this.ui.modals.edit.form.dataset.formId = `edit-${itemID}`;
 
-		this.forms.registerForm(this.ui.modals.edit.form, {cache: false});
+
+		this.modals.edit.handleOpen();
+		this.forms.registerForm(this.ui.modals.edit.form, {cache: false,
+			autoUpload: true,});
+
 
 		this.isPopulating = true;
 		this.populate.populate(this.ui.modals.edit.form, item);
-		this.isPopulating = false;
+		//For quill/taxonomy selector's async setups
+		requestAnimationFrame(() => {
+			requestAnimationFrame(() => {
+				this.isPopulating = false;
+			});
+		});
 
-		this.modals.edit.handleOpen();
 	}
 	openBulkEditModal() {
 		window.removeChildren(this.ui.modals.bulkEdit.selected);
@@ -1120,11 +1333,15 @@
 		}
 		this.modals.bulkEdit.handleOpen();
 
-		this.forms.registerForm(this.ui.modals.bulkEdit.form, {cache:false});
 
+		this.forms.registerForm(this.ui.modals.bulkEdit.form, {cache:false});
 		this.isPopulating = true;
 		this.populate.populate(this.ui.modals.edit.form, item);
-		this.isPopulating = false;
+		requestAnimationFrame(() => {
+			requestAnimationFrame(() => {
+				this.isPopulating = false;
+			});
+		});
 	}
 
 	/*****************************************************************
@@ -1136,8 +1353,11 @@
 			this.cancelBackup();
 			await this.handleBackup();
 		}
-		const changes = await this.changesStore.getAll();
+		let changes = await this.changesStore.getAll();
+		if (changes.length === 0) return;
 
+		// Filter out false positives
+		changes = this.validateChanges(changes);
 		if (changes.length === 0) return;
 
 		if (title === '') {
@@ -1149,8 +1369,6 @@
 
 		changes.forEach(change => {
 			let itemId = change.id;
-
-			// Create a new object without the id field (don't mutate original!)
 			const { id, ...changeWithoutId } = change;
 			allChanges[itemId] = changeWithoutId;
 
@@ -1166,7 +1384,7 @@
 		let operation = {
 			endpoint: this.endpoint,
 			headers: {
-				'action_nonce': window.auth.getNonce('dash'),
+				'X-Action-Nonce': window.auth.getNonce('dash'),
 			},
 			data: {
 				posts: allChanges,
@@ -1178,6 +1396,44 @@
 		this.queue.addToQueue(operation);
 	}
 
+	/**
+	 * Compare pending changes against the store, removing unchanged fields.
+	 * Returns cleaned array (may be empty if nothing actually changed).
+	 */
+	validateChanges(changes) {
+		return changes.reduce((valid, change) => {
+			const { id, content, ...fields } = change;
+			const stored = this.store.get(id);
+
+			if (!stored) {
+				valid.push(change);
+				return valid;
+			}
+
+			const realChanges = { id, content };
+			let hasRealChange = false;
+
+			for (const [name, value] of Object.entries(fields)) {
+				const storedValue = stored.fields?.[name] ?? stored[name];
+				const diff = window.getDifferences.map(storedValue, value);
+
+				if (diff !== null) {
+					realChanges[name] = value;
+					hasRealChange = true;
+				}
+			}
+
+			if (hasRealChange) {
+				valid.push(realChanges);
+			} else {
+				this.changes.delete(id);
+				this.changesStore.delete(id);
+			}
+
+			return valid;
+		}, []);
+	}
+
 
 	setBulkStatus(status) {
 		if (!['publish', 'draft', 'trash', 'delete'].includes(status)) return;
@@ -1365,6 +1621,97 @@
 		});
 	}
 	/***************************************************************
+	 UPLOAD GROUP SUPPORT
+	 Handles:
+	  	- immediate UI feedback once the uploaded groups are sent to server
+	***************************************************************/
+	handleGroupsUploaded(data) {
+		const { posts, fieldId } = data;
+		let uploader = window.jvbUploads;
+		let field = uploader.fields.get(fieldId);
+
+		let added = [];
+		posts.forEach(post => {
+			const placeholderPost = {
+				id: post.groupId,
+				title: post.fields.post_title || `New ${this.singular}`,
+				status: 'draft',
+				date: new Date().toISOString(),
+				modified: new Date().toISOString(),
+				thumbnail: null,
+				icon: this.content,
+				taxonomies: {},
+				fields: post.fields,
+				images: {},
+			};
+
+			post.images.forEach((uploadId, index) => {
+				let id = uploadId['upload_id'];
+				if (index === 0) {
+					placeholderPost.fields['post_thumbnail'] = uploadId;
+				}
+				let upload = uploader.stores.uploads.get(id);
+				if (upload) {
+					placeholderPost.images[id] = {
+						'image-alt-text': '',
+						'image-caption': '',
+						'image-title': upload.fields.originalName,
+						medium: uploader.createPreviewUrl(uploader.formatFile(upload))
+					};
+				}
+
+			});
+			//
+			// // Add to store (won't persist since it's a fake ID)
+			// this.store.data.set(post.groupId, placeholderPost);
+			//
+			//
+			// // Render immediately
+			// let element;
+			// switch (this.view) {
+			// 	case 'grid':
+			// 		element = this.renderGridItem(placeholderPost);
+			// 		this.ui.grid.prepend(element);
+			// 		break;
+			// 	case 'list':
+			// 		element = this.renderListItem(placeholderPost);
+			// 		this.ui.grid.prepend(element);
+			// 		break;
+			// 	case 'table':
+			// 		element = this.renderTableItem(placeholderPost);
+			// 		if (this.ui.table.body) {
+			// 			this.ui.table.body.prepend(element);
+			// 		}
+			// 		break;
+			// }
+			// element.classList.add('uploading');
+			added.push(placeholderPost);
+		});
+		this.store.saveMany(added).then(() => this.render());
+
+
+		this.a11y.announce(`${posts.length} ${posts.length === 1 ? this.singular : this.plural} created. Waiting for server confirmation...`);
+	}
+
+	handleGroupMappings(mappings) {
+		// mappings = { "group_abc123": 456, "group_def456": 789 }
+
+		for (const [groupId, postId] of Object.entries(mappings)) {
+			// Get any pending changes for this temp item
+			let changes = {};
+			if (this.changes.has(groupId)) {
+				changes = this.changes.get(groupId);
+				this.changes.delete(groupId);
+			}
+			let storedChanges = this.changesStore.get(groupId)??{};
+			if (changes.size > 0 || storedChanges.size > 0) {
+				changes = window.deepMerge(storedChanges, changes);
+				this.changes.set(postId, changes);
+				this.scheduleBackup();
+			}
+		}
+	}
+	/***************************************************************
 	 UTILITY
 	***************************************************************/
 	shouldRemoveItemUI(newStatus) {

--
Gitblit v1.10.0