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