/** * Main CRUD Manager - Coordinates everything */ class CRUDManager { constructor(config) { this.queue = window.jvbQueue; this.config = config; this.content = config.content || false; this.settings = window.jvbUserSettings; this.a11y = window.jvbA11y; if (!this.content) { return; } this.isTimeline = false; this.currentItemID = null; this.initElements(); this.updateBulkOptions(); // Initialize components const store = window.jvbStore.register( this.content, { storeName: this.content, keyPath: 'id', endpoint: 'content', headers: { 'action_nonce': window.auth.getNonce('dash'), }, indexes: [ {name: 'id', keyPath: 'id'}, { name: 'status', keyPath: 'status'}, { name: 'date', keyPath: 'date'}, { name: 'modified', keyPath: 'modified'}, { name: 'title', keyPath: 'title'} ], filters: { content: this.content, user: window.auth.getUser(), page: 1, status: 'all', orderby: 'modified', //or title order: 'desc' }, TTL: 30 * 60 * 1000, //30 minutes cache showLoading: true, }); this.store = store[this.content]; this.status = 'all'; this.filterTimeout = null; this.viewController = new window.jvbViews(this.ui.container, this.store); this.tableForm = null; this.tableChanges = new Map(); this.formController = (this.isTimeline) ? new window.jvbForm({collectFormData: () => this.collectTimelineData.bind(this)}) : new window.jvbForm(); this.viewController.subscribe((event, form) => { if (event === 'table-view' && !this.tableForm) { if (!this.tableForm) { this.tableForm = this.formController.registerForm(form, { autosave: false, formStatus: false, isTable: true, }); } } else if (event === 'not-table-view') { if (this.tableForm) { } } else if (event === 'order-changed') { let data = this.store.get(form); if (!data) { return; } let changes = {}; changes[form] = data; this.savePosts(changes, `Updating progression order`); } }); this.formController.subscribe((event, data) => { switch(event) { case 'form-submit': case 'form-autosave': this.handleFormChange(event,data); break; } }); this.queue.subscribe((event, data) => { if (!Object.hasOwn(data, 'endpoint') || !['content', 'uploads/groups'].includes(data.endpoint)) return; if (event === 'operation-completed') { this.handleQueueSuccess(event, data); } else if (event === 'operation-failed-permanent') { this.handleQueueFailure(event, data); } }); // Track initialization this.initialized = false; this.init(); } handleFormChange(event, data) { let title = data.fullData.post_title; let changes = (Object.hasOwn(data, 'changes')) ? data.changes : data.fullData; let theChanges = {}; if (this.isTimeline) { theChanges[this.currentItemID] = changes; this.savePosts(theChanges, title); return; } let itemsToRemove = []; switch (true) { case data.config.element === this.ui.forms.edit: theChanges[this.currentItemID] = changes; title = `Saving ${title} Changes`; // Check if status change requires removal if (changes.post_status && this.shouldRemoveItem(changes.post_status)) { itemsToRemove.push(this.currentItemID); } break; case data.config.element === this.ui.forms.bulkEdit: let selected = data.config.element.querySelectorAll('.selected input:checked'); selected.forEach(sel => { theChanges[sel.value] = changes; // Check if status change requires removal if (changes.post_status && this.shouldRemoveItem(changes.post_status)) { itemsToRemove.push(sel.value); } }); title = `Updating ${selected.length} ${this.config.plural??'posts'} Changes`; break; case data.config.element === this.ui.forms.create: if (event === 'form-submit') { theChanges[data.config.data['form-id']] = changes; title = `Saving ${title} Changes`; } break; } // Handle visual removal with stagger effect if (itemsToRemove.length > 0) { let delay = 0; itemsToRemove.forEach(itemId => { setTimeout(() => { const element = document.querySelector(`.item[data-id="${itemId}"]`); if (element) { window.fade(element, false); } }, delay); delay += 50; // Stagger by 50ms }); // Clear selection after bulk edit with staggered removal if (data.config.element === this.ui.forms.bulkEdit) { setTimeout(() => { this.viewController.clearSelection(); }, delay + 100); } } if (Object.keys(theChanges).length === 0) { return; } this.savePosts(theChanges, title); } shouldRemoveItem(newStatus) { return (this.status === 'all' && !['publish', 'draft'].includes(newStatus)) || (newStatus !== this.status); } savePosts(changes, title) { if (Object.keys(changes).length === 0) { return; } //ensure content is in each post for (let postId in changes) { if (!changes[postId]['content']) { changes[postId]['content'] = this.content; } } let operation = { endpoint: 'content', headers: { 'action_nonce': window.auth.getNonce('dash'), }, data: { posts: changes, }, popup: `Saving changes`, title: title }; this.queue.addToQueue(operation); } async handleQueueSuccess(event, data) { this.store.clearCache(); this.store.fetch(); } handleQueueFailure(event, data) { console.error('Operation failed permanently:', data); // Optionally show error notification to user this.a11y?.announce(`Operation failed: ${data.error_message || 'Unknown error'}`); } initElements() { this.elements = { modals: { create: 'dialog.create', edit: 'dialog.edit', bulkEdit: 'dialog.bulkEdit' }, container: '.crud[data-content]', grid: '.item-grid', bulkSelectActions: '.bulk-action-select', forms: { create: 'dialog.create form', edit: 'dialog.edit form', bulkEdit: 'dialog.bulkEdit form' }, uploader: 'details.uploader' }; this.ui = window.uiFromSelectors(this.elements); if (this.ui.uploader) { window.jvbUploads.scanFields(document.querySelector(this.elements.uploader)); window.jvbUploads.subscribe((event, data) => { if (event === 'sent-to-queue') { console.log(data); if (data === this.ui.uploader.querySelector('[data-uploader]')?.dataset.uploader) { window.debouncer.schedule('crud-complete', ()=> { this.store.clearHttpHeaders(); }); } } }); } this.isTimeline = !!document.querySelector('[data-timeline]'); } init() { if (this.ui.uploader){ this.settings.addSetting(this.ui.uploader, 'open'); this.ui.uploader.addEventListener('toggle', (e) =>{ this.settings.saveSetting('open', this.ui.uploader.open ? 'on' : 'off'); }); } // Set up filter controls this.filterHandler = this.handleFilterChange.bind(this); this.changeHandler = this.handleChange.bind(this); this.modals = {}; for (let [name, modal] of Object.entries(this.ui.modals)) { this.modals[name] = new window.jvbModal(modal); this.modals[name].subscribe((event, data) => { switch (event) { case 'modal-close': this.currentItemID = null; this.formController.cleanupForm(this.modals[name].modal.querySelector('form').dataset.formId); //double check we have finished saving break; case 'modal-open': //probably not needed in this class break; } }); } // Set up global event delegation this.setupEventDelegation(); this.setupFilters(); this.initialized = true; } setupEventDelegation() { document.addEventListener('change', this.changeHandler); // Single event listener for all CRUD actions document.addEventListener('click', (e) => { // Check for action buttons const actionBtn = e.target.closest('[data-action]'); if (actionBtn) { e.preventDefault(); const action = actionBtn.dataset.action; const id = actionBtn.dataset.id; switch(action) { case 'edit': this.populateEditForm(id); this.modals.edit.handleOpen(); break; case 'delete': if (confirm('Delete this item?')) { let changes = {}; changes[actionBtn.dataset.id] = { 'post_status': 'delete', 'content': this.content }; window.fade(actionBtn.closest('.item'), false); this.savePosts(changes, `Sending ${this.singular} to trash...`); this.store.delete(id); } break; case 'trash': let changes = {}; changes[actionBtn.dataset.id] = { 'post_status': 'trash', 'content': this.content }; window.fade(actionBtn.closest('.item'), false); this.savePosts(changes, `Sending ${this.singular} to trash...`); break; case 'create': this.modals.create.dataset.itemId = 'new'; this.modals.create.dataset.content = this.content; this.modals.create.handleOpen(); break; case 'bulk-edit': const selected = Array.from(this.viewController.selectedItems); if (selected.length > 0) { this.modals.bulkEdit.handleOpen(); } break; case 'bulk-delete': const toDelete = Array.from(this.viewController.selectedItems); if (toDelete.length > 0 && confirm(`Delete ${toDelete.length} items?`)) { toDelete.forEach(id => this.store.delete(id)); this.viewController.clearSelection(); } break; case 'sync': // this.store.syncQueue(); break; case 'refresh': this.store.fetch(); break; } } let createButton = e.target.closest('.create-item'); if (createButton) { this.formController.registerForm(this.ui.forms.create); this.modals.create.handleOpen(); } let clearSelection = e.target.closest('.cancel-bulk'); if (clearSelection) { this.viewController.selectAll(false); } }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { // Ctrl/Cmd + A to select all if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if (this.ui.container && this.ui.container.contains(document.activeElement)) { e.preventDefault(); this.viewController.selectAll(); } } // ESC to clear selection if (e.key === 'Escape' && this.viewController?.selectedItems.size > 0 && window.jvbModal.getAllModals().length === 0) { this.viewController.clearSelection(); } }); } handleChange(e) { if (e.target.closest('[data-id]')) { if (this.isTimeline) { this.handleTimelineTableChange(e); } else { this.handleTableChange(e); } return; } if (e.target.classList.contains('bulk-action-select')) { if (e.target.value.startsWith('tax-')) { const taxonomy = e.target.value.replace('tax-', ''); this.openTaxonomyModal(taxonomy); e.target.value = ''; return; } switch (e.target.value) { case 'edit': this.populateBulkEdit(); this.modals.bulkEdit.handleOpen(); break; case 'publish': this.setBulkStatus('publish'); break; case 'draft': this.setBulkStatus('draft'); break; case 'trash': this.setBulkStatus('trash'); break; case 'restore': this.setBulkStatus('draft'); break; case 'delete': this.setBulkStatus('delete'); break; } } if (window.targetCheck(e, 'select[data-filter]')) { this.handleFilterChange(e); } } handleTableChange(e) { const row = e.target.closest('tr[data-id]'); if (!row) return; const input = e.target; const postID = parseInt(row.dataset.id); const fieldName = input.closest(['data-field'])?.dataset.field; if (!fieldName) return; const item = this.store.get(postID); if (!item) return; item.fields[fieldName] = this.getInputValue(input); this.store.save(item); let post = {}; post[postID] = item.fields; this.savePosts(post, `Saving changes to ${this.content}`); } handleTimelineTableChange(e) { const tbody = e.target.closest('tbody[data-id]'); if (!tbody) return; const input = e.target; const fieldName = input.closest('[data-field]')?.dataset.field; if (!fieldName) return; const parentID = parseInt(tbody.dataset.id); const timelinePoint = input.closest('tr.timeline-point'); const item = this.store.get(parentID); if (!item) return; const value = this.getInputValue(input); // Check if this is a specific point, or a shared value if (timelinePoint) { const imgID = timelinePoint.dataset.imageId; if (!item.fields.timeline) { item.fields.timeline = {}; } if (!item.fields.timeline[imgID]) { item.fields.timeline[imgID] = {}; } item.fields.timeline[imgID][fieldName] = value; } else { item.fields[fieldName] = value; } //Update store directly this.store.save(item); let changes = {}; changes[parentID] = item.fields; this.savePosts(changes, 'Updating progress post'); } getInputValue(input) { if (input.type === 'checkbox') { return input.checked ? (input.value || '1') : ''; } if (input.type === 'radio') { return input.checked ? input.value : null; } return input.value; } openTaxonomyModal(taxonomy) { // Check if jvbSelector exists if (!window.jvbSelector) { console.error('TaxonomySelector not initialized'); return; } // Open the selector in filter mode window.jvbSelector.openForFilter( taxonomy, (selectedIds, taxonomy) => this.handleBulkTaxonomy(selectedIds, taxonomy) ); } handleBulkTaxonomy(selectedIds, taxonomy) { // Callback when terms are selected if (selectedIds.length > 0) { selectedIds = selectedIds.join(','); let changes = {}; let selected = Array.from(this.viewController.selectedItems); selected.forEach(sel => { changes[sel] = { content: this.content }; changes[sel][taxonomy] = selectedIds; }); let title = `Adding ${selected.length} ${this.config.plural??'posts'} to ${selectedIds.length} ${jvbSettings.labels[taxonomy].plural}`; this.viewController.clearSelection(); this.savePosts(changes, title); } } setBulkStatus(status) { if (!['publish', 'draft', 'trash', 'delete'].includes(status)){ return; } let changes = {}; for (let selected of this.viewController.selectedItems) { changes[selected] = { post_status: status, content: this.content }; } let title; switch (status) { case 'delete': title = 'Deleting'; break; default: title = window.uppercaseFirst(status)+'ing'; } if ((this.status === 'all' && !['publish', 'draft'].includes(status)) || status !== this.status) { let delay = 0; for (let selected of this.viewController.selectedItems) { setTimeout(() => { const element = document.querySelector(`.item[data-id="${selected}"]`); if (element) { window.fade(element, false); } }, delay); delay += 50; // Increment delay for staggered effect } } // Clear selection even if items aren't being removed this.viewController.clearSelection(); if (Object.keys(changes).length !== 0) { this.savePosts(changes, `${title} ${this.viewController.selectedItems.size} ${this.plural}...`); } } handleFilterChange(e) { let target = e.target; let filter = target.dataset.filter; if (filter === 'taxonomies') { let taxonomy = target.dataset.taxonomy; this.store.setFilter(`tax_${taxonomy}`, target.value); } else { this[target.dataset.filter] = target.value; this.store.setFilter(target.dataset.filter, target.value); if (target.dataset.filter === 'status') { this.updateBulkOptions(target.value); } } } updateBulkOptions(status = 'all') { if (status === 'trash') { if (this.ui.bulkSelectActions?.querySelector('[value="edit"]')) { window.removeChildren(this.ui.bulkSelectActions); let options = window.getTemplate('trashOptions'); options.querySelectorAll('option').forEach((option, index) => { if (index === 0) { option.checked = true; } this.ui.bulkSelectActions.append(option); }); } } else { if (this.ui.bulkSelectActions && !this.ui.bulkSelectActions.querySelector('[value="edit"]')) { window.removeChildren(this.ui.bulkSelectActions); let options = window.getTemplate('notTrashOptions'); options.querySelectorAll('option').forEach((option, index) => { this.ui.bulkSelectActions.append(option); }); } } if (this.ui.bulkSelectActions) { this.ui.bulkSelectActions.value = ''; } } populateBulkEdit() { const container = this.modals.bulkEdit.modal.querySelector('form .selected'); if (!container) return; window.removeChildren(container); for (let selected of this.viewController.selectedItems) { let item = this.store.get(selected); const img = window.getTemplate('bulkItem'); if (!img) return; const checkbox = img.querySelector('input[type=checkbox]'); const image = img.querySelector('img'); if (checkbox) { checkbox.id = `bulk_${item.id}`; checkbox.value = item.id; checkbox.checked = true; } if (image && item.thumbnail) { image.src = item.thumbnail; image.alt = item.alt || ''; } container.append(img); } let modal = this.modals.bulkEdit.modal; [ modal.querySelector('h2 span').textContent ] = [ this.viewController.selectedItems.size ]; this.formController.registerForm(this.ui.forms.bulkEdit); } populateEditForm(itemID) { this.currentItemID = itemID; let item = this.store.get(parseInt(itemID)); if (item) { this.ui.modals.edit.dataset.itemId = itemID; this.ui.modals.edit.dataset.content = this.content; let form = this.ui.modals.edit.querySelector('form'); this.ui.modals.edit.querySelector('h2').textContent = `Editing ${item.fields.post_title}`; form.dataset.formId = `edit-${itemID}`; new window.jvbPopulate(form, item); this.formController.registerForm(this.ui.forms.edit); } } setupFilters() { // Search const searchInput = document.querySelector('input[type="search"]'); if (searchInput) { let searchTimeout; searchInput.addEventListener('input', () => { if (searchInput.value.length > 3) { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { this.store.setFilter('search', searchInput.value); }, 300); } else if (searchInput.value.length === 0) { this.store.removeFilter('search'); } }); } } destroy() { document.querySelectorAll('[data-filter]').forEach(filter => { filter.removeEventListener('change', this.filterHandler); }); } } // Initialize when ready document.addEventListener('DOMContentLoaded', async function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { let container = document.querySelector('[data-content]'); if (container && !Object.hasOwn(container.dataset, 'ignore')) { window.crudManager = new CRUDManager({ content: container.dataset.content, }); } } }); });