class CRUDManager { constructor(){ this.container = document.querySelector('.crud[data-content]:not([data-ignore])'); if (!this.container) return; this.content = this.container.dataset.content; this.endpoint = this.container.dataset.endpoint??'content'; this.singular = this.container.dataset.singular; this.plural = this.container.dataset.plural; this.queue = window.jvbQueue; this.a11y = window.jvbA11y; this.error = window.jvbError; this.populate = window.jvbPopulate; this.cache = new window.jvbCache(this.content); this.uploadedFields = new Set(); //tracks which upload fields are currently uploading; so don't send any of these changes to server this.activeItem = null; this.isTimeline = false; this.isPopulating = false; //Track Changes this.changes = new Map(); this.items = new Map(); this.init(); } init() { this.initElements(); this.initListeners(); this.defineTemplates(); let cached = this.initSettings(); this.initStore(cached); this.checkHideFilters(); this.initIntegrations(); this.initUploader(); this.initModals(); } defineTemplates() { const T = window.jvbTemplates; const crud = this; const baseSetup = (el, refs, data) => { el.dataset.itemId = data.id; 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}`; if (refs.edit) refs.edit.dataset.id = data.id; 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] ?? {}; refs.img.src = thumbnail.medium??''; refs.img.alt = thumbnail.alt??data.fields.post_title??''; } } T.define('gridView', { refs: { img: 'img', checkbox: '.select-item', selectLabel: 'label.select-item-label', edit: '[data-action="edit"]', trash: '[data-action="trash"]' }, setup({ el, refs, manyRefs, data }) { baseSetup(el, refs, data); imageSetup(el, refs, data); } }); T.define('listView', { refs: { img: 'img', checkbox: '.select-item', selectLabel: 'label.select-item-label', edit: '[data-action="edit"]', trash: '[data-action="trash"]' }, manyRefs: { attrs: '[data-attr]', fields: '[data-field]' }, setup({ el, refs, manyRefs, data }) { baseSetup(el, refs, data); imageSetup(el, refs, data); manyRefs?.attrs?.forEach(el => { const value = data[el.dataset.attr]; if (value && value !=='') { el.textContent = value; } else { el.remove(); } }); manyRefs?.fields?.forEach(el => { const value = data.fields?.[el.dataset.field]; if (value && value !== '') { el.tagName === 'DIV' ? el.innerHTML = value : el.textContent = value; } else { el.remove(); } }); } }); let tableRefs = {}; let tableMany = {}; if (this.isTimeline) { tableRefs.sharedRow = 'tr.shared'; tableRefs.point = 'tr.timeline-point'; } T.define('tableView', { refs: { checkbox: '.select-item', selectLabel: 'label.select-item-label', ... tableRefs, }, manyRefs: { inputs: 'input,select,textarea', status: 'input[name="post_status"]', selectors: '[data-type="selector"]', fields: '[data-field]', ... tableMany, }, setup({ el, refs, manyRefs, data }) { baseSetup(el, refs, data); manyRefs?.inputs?.forEach(el => { let wrapper = el.closest('[data-field]'); window.prefixInput(el, `${data.id}-`, wrapper); }); manyRefs?.status?.forEach(el => { if (el.value === data.status) { el.checked = true; } }); if (crud.isTimeline) { if (refs.sharedRow) { refs.sharedRow.querySelectorAll('input,select,textarea').forEach(input => { let wrapper = input.closest('[data-field]'); window.prefixInput(input, `${data.id}-`, wrapper); }); crud.populate.populate(refs.sharedRow, data); // Handle status radios in shared row refs.sharedRow.querySelectorAll('input[name="post_status"]').forEach(el => { if (el.value === data.status) { el.checked = true; } }); } if (refs.point && data.fields?.timeline) { Object.entries(data.fields.timeline).forEach(([nuthing, timeline], index) => { const point = refs.point.cloneNode(true); point.dataset.index = `${index}`; point.dataset.itemId = timeline.id; point.querySelectorAll('input,select,textarea').forEach(input => { let wrapper = input.closest('[data-field]'); window.prefixInput(input, `${timeline.id}-`, wrapper); }); crud.populate.populate(point, { fields: timeline, images: data.images, taxonomies: data.taxonomies }); const imgData = data.images?.[timeline.post_thumbnail]; if (imgData) { point.querySelector('.field.upload')?.setAttribute('title', imgData['image-title']??''); } el.insertBefore(point, refs.point); }); refs.point.remove(); } } else { if (crud.ui.table.form?.dataset.edit !== undefined) { // Non-timeline: prefix all inputs normally manyRefs?.inputs?.forEach(input => { let wrapper = input.closest('[data-field]'); window.prefixInput(input, `${data.id}-`, wrapper); }); manyRefs?.status?.forEach(el => { if (el.value === data.status) { el.checked = true; } }); crud.populate.populate(el, data); } else { const fields = (Object.hasOwn(data, 'fields')) ? data.fields : data; manyRefs?.fields?.forEach(field => { if (Object.hasOwn(fields, field.dataset.field) && fields[field.dataset.field] !== '') { let value = fields[field.dataset.field]; let p = fields.children[0]; if (p) { p.textContent = field.dataset.field === 'date' ? window.formatTimeAgo(value) : value; } } }); } } manyRefs?.selectors?.forEach(selector => selector.setAttribute('data-lazy', '')); } }); T.define('emptyState'); T.define('bulkItem', { refs: { checkbox: 'input', img: 'img', label: 'label' }, setup({el, refs, manyRefs, data}) { if (refs.checkbox) { refs.checkbox.id = `bulk_${data.id}`; refs.checkbox.value = data.id; refs.checkbox.checked = true; refs.checkbox.name ='selected[]'; } let thumbnail = data?.images[data?.fields?.post_thumnbail]??{}; if (refs.img && Object.keys(thumbnail).length >0) { refs.img.src = thumbnail.medium??''; refs.img.alt = thumbnail.alt??''; } if (refs.label) { refs.label.title = item.fields.post_title; } } }); T.define('trashOptions'); T.define('notTrashOptions'); T.define('contentTable'); } initElements() { this.allowedFilters = ['status', 'orderby', 'order', 'search', 'date-filter', 'dateFrom', 'dateTo']; this.selectors = { buttons: { create: '.create-item', clearFilters: '[data-action="clear-filters"]' }, views: { grid: 'input[data-view="grid"]', list: 'input[data-view="list"]', table: 'input[data-view="table"]' }, modals: { create: { modal: 'dialog.create', form: 'dialog.create form', h2: 'dialog.create h2', }, edit: { modal: 'dialog.edit', form: 'dialog.edit form', h2: 'dialog.edit h2', }, bulkEdit: { modal: 'dialog.bulkEdit', selected: 'dialog.bulkEdit .selected', h2: 'dialog.bulkEdit h2 span', form: 'dialog.bulkEdit form' }, date: { modal: 'dialog.date-range', start: 'dialog.date-range .date-start', end: 'dialog.date-range .date-end', month: 'dialog.date-range .month-select', } }, grid: `.${this.content}.item-grid`, table: { nav: '#vertical', form: 'form.table', table: 'form.table table', body: 'form.table body', head: 'form.table thead', foot: 'form.table tfoot', selectedColumns: '.all-filters .multi-select', columns: 'thead th', }, bulk: { action: '.bulk-action-select', count: '.bulk-controls .selected-count', control: '.bulk-controls .bulk-actions', select: '.bulk-controls select', selectAll: '.select-all' }, filters: { container: 'details.all-filters', search: '.all-filters input[type="search"]', status: { all: '[name="status"]#all', publish: '[name="status"]#publish', draft: '[name="status"]#draft', trash: '[name="status"]#trash', }, orderby: { date: '[name="orderby"]#date', alphabetical: '[name="orderby"]#alphabetical', }, order: { asc: '[name="order"][value="asc"]', desc: '[name="order"][value="desc"]' }, date: '[data-filter="date"]' }, uploader: 'details.uploader' } this.ui = window.uiFromSelectors(this.selectors); const taxFilters = document.querySelectorAll('[data-filter="taxonomies"]'); if (taxFilters.length > 0) { this.ui.filters.taxonomies = {}; taxFilters.forEach(tax => { const taxonomy = tax.dataset.taxonomy; this.ui.filters.taxonomies[taxonomy] = tax; this.allowedFilters.push(`tax_${taxonomy}`); }); } this.isTimeline = !!document.querySelector('[data-timeline]'); } initUploader() { if (!this.ui.uploader) return; window.jvbUploads.scanFields(this.ui.uploader); window.jvbUploads.subscribe((event, data) => { if (event === 'sent-to-queue') { if (data === this.ui.uploader.dataset.uploader) { 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) { this.uploadedFields.add(`${itemId}_${fieldName}`); if (this.changes.has(itemId)) { delete this.changes.get(itemId)[fieldName]; } } } if (event === 'upload_complete') { this.uploadedFields.delete(`${data['item_id']}_${data['field']}`); } }); } initModals() { this.modals = {}; for (let [name, modal] of Object.entries(this.ui.modals)) { if (!modal.modal) continue; this.modals[name] = new window.jvbModal(modal.modal); this.modals[name].subscribe((event, data) => { switch (event) { case 'modal-close': const formId = this.ui.modals[name].form.dataset.formId; if (formId) { this.forms.clearForm(formId); } this.resetForm(this.ui.modals[name].form); 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': break; } }) } } initStore(cached) { let filters = { ... this.defaults, ...cached }; const stores = window.jvbStore.register( this.content, [ { storeName: this.content, keyPath: 'id', endpoint: this.endpoint??'content', //for taxonomy stores headers: { 'X-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: filters, ignore: ['content', 'user'], TTL: 60 * 60 * 1000, //1 hour cache showLoading: true, }, { storeName: 'changes', keyPath: 'id' } ] ); this.changesStore = stores['changes']; this.store = stores[this.content]; this.store.subscribe((event, data) => { switch (event) { case 'data-loaded': this.render(); this.selectionHandler.collectItems(); break; } }); this.changesStore.subscribe((event, data) => { switch (event) { case 'data-ready': let changes = this.changesStore.getAll(); if (changes.length > 0) { changes.forEach(change => { this.changes.set(change.id, change); }); this.savePosts('', false).then(()=>{}); } break; } }); } initIntegrations() { this.selected = new Set(); this.selectionHandler = new window.jvbHandleSelection(this.container, { selectAll: { checkbox: '#select-all', label: '.bulk-select label', span: '.bulk-select label span' }, wrapper: { wrapper: '.wrap' }, item: { idAttribute: 'itemId' } }); this.selectionHandler.subscribe((event, data) => { this.selected = new Set([...data.selectedItems].map(id => parseInt(id))); this.ui.bulk.control.hidden = this.selected.size === 0; this.ui.bulk.count.hidden = this.selected.size === 0; this.ui.bulk.count.textContent = `${this.selected.size} ${this.plural} selected`; }); this.forms = window.jvbForm; // this.forms.subscribe((event, data) => { // switch(event) { // case 'form-submit': // case 'form-autosave': // // this.handleFormChange(event,data); // break; // } // }); 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 === 'uploads/groups') { if (data.result && data.result.group_mappings) { this.handleGroupMappings(data.result.group_mappings); } this.store.clearCache(); } if (event === 'operation-status' && data.status === 'completed' && data.type === 'content_update') { this.store.clearCache(); // Check for result data (from ContentExecutor) if (!data.result || !data.result.posts) { console.warn('Content update completed but no result.posts', data); return; } // Get successfully processed post IDs const successfulIds = Object.keys(data.result.posts); if (successfulIds.length === 0) { return; } // Clear from both persistent and in-memory storage this.changesStore.deleteMany(successfulIds); successfulIds.forEach(id => this.changes.delete(id)); } }); } initSettings() { this.defaults = { content: this.content, user: window.auth.getUser(), page: 1, status: 'all', orderby: 'date', order: 'desc', search: '', } let updateFilters = {}; //current view (defaults to grid) let defaultView = this.container.dataset.view??'grid' this.view = this.cache.get('view')??defaultView; if (this.view !== defaultView) { this.ui.views[this.view].checked = true; } //current status (defaults to all) this.status = this.cache.get('status')??this.defaults.status; if (this.status !== this.defaults.status) { this.ui.filters.status[this.status].checked = true; updateFilters.status = this.status; } //orderby & order this.orderby = this.cache.get('orderby')??this.defaults.orderby; if (this.orderby !== this.defaults.orderby) { this.ui.filters.orderby[this.orderby].checked = true; updateFilters.orderBy = this.orderby; } this.order = this.cache.get('order')??this.defaults.order; if (this.order !== this.defaults.order) { this.ui.filters.order[this.order].checked = true; updateFilters.order = this.order; } if (this.ui.filters.taxonomies) { Object.entries(this.ui.filters.taxonomies).forEach(([taxonomy, element]) => { const filterKey = `tax_${taxonomy}`; const cached = this.cache.get(filterKey); if (cached) { element.value = cached; updateFilters[filterKey] = cached; } }); } let tabDirection = this.cache.get('tabNav')??'horizontal'; if (this.ui.table.nav && tabDirection === 'vertical') { this.ui.table.nav.checked = true; } //Setup details open functionality let details = { showFilters: { element: this.ui.filters.container, default: 'closed', }, showUploader: { element: this.ui.uploader, default: 'open' } }; for (let [name, conf] of Object.entries(details)) { if (conf.element) { let cached = this.cache.get(name)??conf.default; conf.element.open = cached === 'open'; conf.element.addEventListener('toggle', ()=> { this.cache.set(name, conf.element.open ? 'open' : 'closed'); }); } } return updateFilters; } /**************************************************************** EVENT LISTENERS ****************************************************************/ initListeners() { this.changeHandler = this.handleChange.bind(this); this.clickHandler = this.handleClick.bind(this); this.inputHandler = this.handleInput.bind(this); this.submitHandler = this.handleModalSubmit.bind(this); document.addEventListener('change', this.changeHandler); document.addEventListener('click', this.clickHandler); if (this.ui.filters.search) { this.ui.filters.search.addEventListener('input', this.inputHandler); } for (let [name, modal] of Object.entries(this.ui.modals)) { if (modal.form) { modal.form.addEventListener('submit', this.submitHandler); } } } handleModalSubmit(e) { e.preventDefault(); const form = e.target; const modal = form.closest('dialog'); if (!modal) return; if (modal.classList.contains('create')) { this.handleCreateSubmit(modal); return; } let title = `Saving changes for multiple ${this.plural}`; this.scheduleSave(0); } 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 const inItem = e.target.closest('[data-item-id]'); const isFilter = e.target.matches('[data-filter]'); const isBulkAction = e.target.matches('.bulk-action-select'); const isView = e.target.matches('[data-view]'); if (!inItem && !isFilter && !isBulkAction && !isView) return; if (!this.isPopulating && inItem && !e.target.closest('[data-ignore], .select-item')) { this.handleItemUpdate(e); return; } if (isView) { this.items.clear(); this.handleViewChange(e.target); return; } if (isBulkAction) { this.handleBulkAction(e.target); return; } if (isFilter) { this.handleFilterChange(e.target); return; } // Table-specific handlers if (this.view === 'table') { if (e.target.matches('details.multi-select')) { this.toggleColumn(e.target.id, e.target.checked); return; } if (e.target.matches(this.selectors.table.nav)) { this.tabNav = e.target.checked; this.cache.set('tabNav', e.target.checked ? 'vertical' : 'horizontal'); } } } handleBulkAction(bulkAction) { if (bulkAction.value.startsWith('tax-')) { const selectedOption = bulkAction.options[bulkAction.selectedIndex]; const taxonomy = selectedOption.dataset.taxonomy; const single = selectedOption.dataset.single; const plural = selectedOption.dataset.plural; window.jvbSelector.openEmpty( taxonomy, single, plural, (result) => this.handleBulkTaxonomy(result) ); bulkAction.value = ''; return; } switch(bulkAction.value) { case 'edit': this.openBulkEditModal(); break; case 'publish': case 'trash': case 'delete': this.setBulkStatus(bulkAction.value); break; case 'draft': case 'restore': this.setBulkStatus('draft'); break; } } handleBulkTaxonomy(result) { if (!result.termIds.length || !this.selected.size) return; this.selected.forEach(itemID => { const item = this.store.get(itemID); if (!item) return; // Merge existing terms with new ones const existingTerms = item.taxonomies?.[result.taxonomy] || []; const existingIds = existingTerms.map(t => t.id); const newIds = [...new Set([...existingIds, ...result.termIds])]; this.updateItem(itemID, result.taxonomy, newIds); }); this.savePosts(`Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...`,).then(()=> {}); this.selectionHandler.clearSelection(); } handleItemUpdate(e) { let item = window.targetCheck(e, '[data-item-id]'); if (!item) return; item.dataset.itemId.split(',').forEach(itemId => { let field = this.forms.getField(e.target); if (['repeater', 'tag-list'].includes(field.dataset.fieldType)) { return; } let name = field.dataset.field; let value = this.forms.getFieldValue(e.target); this.updateItem(itemId, name, value); }); } updateItem(itemId, name, value) { if (!this.changes.has(itemId)) { this.changes.set(itemId, { id: itemId, content: this.content }); } this.changes.get(itemId)[name] = value; for (const key of this.uploadedFields) { const [itemId, fieldName] = key.split('_'); if (this.changes.has(itemId)) { delete this.changes.get(itemId)[fieldName]; } } this.scheduleBackup(); //Only send actual itemIds to server. If this is a recently uploaded item, just store changes for now if (typeof itemId === 'number' || !itemId.includes('group')) { this.scheduleSave(); } } scheduleBackup() { window.debouncer.schedule( `changes-${this.content}`, async () => { if (this.changes.size > 0) { await this.handleBackup(); } }, 2000 ); } cancelBackup() { window.debouncer.cancel(`changes-${this.content}`); } async handleBackup() { 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; if (filter === 'date' && target.value === 'custom') { target.value = ''; this.modals.date.handleOpen(); return; } if (filter === 'date' && target.value !== '') { this.setFilter('date-filter', target.value); // Clear custom range this.deleteFilter('dateFrom'); this.deleteFilter('dateTo'); this.checkHideFilters(); return; } if (filter === 'taxonomies') { filter = `tax_${target.dataset.taxonomy}`; } this.setFilter(filter, target.value); } checkHideFilters() { const filters = this.store.filters; const hasActiveFilter = Object.entries(filters).some(([key, value]) => { // Skip internal props if (['content', 'user', 'page'].includes(key)) return false; // Check if differs from default return this.defaults[key] !== value && value !== '' && value !== null; }); this.ui.buttons.clearFilters.hidden = !hasActiveFilter; } clearAllFilters() { let currentFilters = this.store.filters; this.store.clearFilters(); for (let [filter, value] of Object.entries(currentFilters)) { this.cache.remove(filter); this.deleteFilter(filter, value); } this.a11y.announce('All filters cleared'); } handleCustomDateSelection() { // Check if month select was used if (this.ui.modals.date.month && this.ui.modals.date.month.value) { const [year, month] = this.ui.modals.date.month.value.split('-'); const firstDay = `${year}-${month}-01`; const lastDay = new Date(year, parseInt(month), 0).getDate(); const lastDayFormatted = `${year}-${month}-${String(lastDay).padStart(2, '0')}`; this.setFilter('dateFrom', firstDay); this.setFilter('dateTo', lastDayFormatted); // Clear the regular date-filter this.deleteFilter('date-filter'); // Reset month select for next time this.ui.modals.date.month.value = ''; } // Otherwise check custom range else if (this.ui.modals.date.start && this.ui.modals.date.start.value && this.ui.modals.date.end && this.ui.modals.date.end.value) { this.setFilter('dateFrom', this.ui.modals.date.start.value); this.setFilter('dateTo', this.ui.modals.date.end.value); // Clear the regular date-filter this.deleteFilter('date-filter'); // Reset inputs for next time this.ui.modals.date.start.value = ''; this.ui.modals.date.end.value = ''; } this.checkHideFilters(); } handleViewChange(view) { this.view = view.dataset.view; this.cache.set('view', this.view); this.render(); } handleClick(e) { // Use matches() instead of closest() where possible (faster) if (e.target.matches('.clear-search')) { this.deleteFilter('search', ''); return; } const actionButton = e.target.closest('[data-action]'); if (actionButton) { e.preventDefault(); this.handleActionButton(actionButton); return; } if (e.target.matches('.apply-date-filter')) { this.handleCustomDateSelection(); this.modals.date.handleClose(); return; } if (e.target.matches(this.selectors.buttons.create)) { this.openCreateModal(); } } openCreateModal(){ 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) { const itemID = button.dataset.id; switch (button.dataset.action) { case 'edit': this.openEditModal(itemID); break; case 'delete': if (confirm('Delete this item? This cannot be undone')) { this.updateItem(itemID, 'post_status', 'delete'); window.fade(button.closest('.item'), false); this.savePosts(`Permanently deleting ${this.singular}...`).then(()=>{}); this.store.delete(itemID); } break; case 'trash': if (this.status === 'trash') { if (confirm('Delete this item? This cannot be undone')) { this.updateItem(itemID, 'post_status', 'delete'); window.fade(button.closest('.item'), false); this.savePosts(`Permanently deleting ${this.singular}...`).then(()=>{}); this.store.delete(itemID); } } else { this.updateItem(itemID, 'post_status', 'trash'); window.fade(button.closest('.item'), false); this.savePosts(`Sending ${this.singular} to trash...`).then(()=>{}); } break; case 'bulk-edit': if (this.selected.size > 0) { this.openBulkEditModal(); } break; case 'bulk-delete': this.handleBulkDelete(); break; case 'refresh': this.store.clearCache(); this.store.fetch(); break; case 'clear-filters': this.clearAllFilters(); break; } } handleBulkDelete() { let isTrash = this.status === 'trash'; if (this.selected.size > 0 && confirm(`${isTrash ? 'Permanently delete' : 'Send'} ${this.selected.size} ${this.selected.size === 1 ? this.singular : this.plural}${isTrash ? '' : 'to trash'}?`)) { this.selected.forEach(id => { this.store.delete(id); this.updateItem(id, 'post_status', isTrash ? 'delete' : 'trash'); }); let title = isTrash ? `Permanently deleting ${this.selected.size} ${this.selected.size === 1 ? this.singular : this.plural}` : `Sending ${this.selected.size} ${this.selected.size === 1 ? this.singular : this.plural} to trash`; this.savePosts(title).then(()=>{}); this.selectionHandler.clearSelection(); } } handleInput(e) { e.preventDefault(); e.stopPropagation(); let query = e.target.value.trim(); let key = `${this.content}-search`; if (query.length === 0) { this.deleteFilter('search', ''); return; } // Require minimum 2 characters // if (query.length < 2) { // return; // } window.debouncer.schedule( key, () => { this.a11y.announce(`Searching for "${query}"...`); this.store.setFilters({ search: query, page: 1 }); }, 300 ); } handleKeys(e) { if (!this.tabNav) return; if (e.key === 'Tab') { e.preventDefault(); const currentCell = e.target.closest('[data-field]'); const currentRow = e.target.closest('tr'); if (!currentCell || !currentRow) return; const fieldName = currentCell.dataset.field; const isShift = e.shiftKey; // Find next editable row let targetRow = this.findNextEditableRow(currentRow, isShift); // If no target row found, wrap around if (!targetRow) { targetRow = this.wrapToRow(currentRow, isShift); } if (targetRow) { this.focusFieldInRow(targetRow, fieldName, isShift); } } } findNextEditableRow(currentRow, goBackward = false) { let row = goBackward ? currentRow.previousElementSibling : currentRow.nextElementSibling; // For timeline tables, skip non-editable rows while (row && !this.isEditableRow(row)) { row = goBackward ? row.previousElementSibling : row.nextElementSibling; } return row; } wrapToRow(currentRow, goBackward = false) { if (this.isTimeline) { // For timeline, stay within the same tbody const tbody = currentRow.closest('tbody'); if (!tbody) return null; const rows = Array.from(tbody.querySelectorAll('tr')) .filter(row => this.isEditableRow(row)); return goBackward ? rows[rows.length - 1] : rows[0]; } else { // For regular tables, use all rows in tbody if (!this.ui.table.body) return null; const rows = Array.from(this.ui.table.body.querySelectorAll('tr')) .filter(row => this.isEditableRow(row)); return goBackward ? rows[rows.length - 1] : rows[0]; } } isEditableRow(row) { // Skip thead/tfoot if (row.closest('thead') || row.closest('tfoot')) { return false; } // For timeline, check for specific classes if (this.isTimeline) { return row.classList.contains('shared') || row.classList.contains('timeline-point'); } // For regular tables, check for data-id return !!row.dataset.itemId; } focusFieldInRow(row, fieldName, fromAbove = false) { const targetCell = row.querySelector(`[data-field="${fieldName}"]`); if (!targetCell) return; const input = this.findFocusableInput(targetCell); if (input) { input.focus(); // Select text if it's a text input if (input.select && input.type === 'text') { input.select(); } // Announce for accessibility const direction = fromAbove ? 'next' : 'previous'; this.a11y?.announce(`Moved to ${fieldName} in ${direction} row`); } } findFocusableInput(cell) { const selectors = [ 'input:not([type="hidden"]):not([disabled])', 'textarea:not([disabled])', 'select:not([disabled])', 'button:not([disabled])' ]; for (const selector of selectors) { const element = cell.querySelector(selector); if (element) return element; } return null; } /******************************************************************* MODALS *******************************************************************/ openEditModal(itemID) { let item = this.store.get(parseInt(itemID)); if (!item) return; 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}`; this.ui.modals.edit.form.dataset.formId = `edit-${itemID}`; 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; this.modals.edit.handleOpen(); } openBulkEditModal() { window.removeChildren(this.ui.modals.bulkEdit.selected); this.ui.modals.edit.form.reset(); window.chunkIt( this.selected, (itemId) => { let item = this.store.get(parseInt(itemId)); if (!item) return; itemIds.push(item.id); return window.jvbTemplates.create('bulkItem', item); }, (fragment) => this.ui.modals.bulkEdit.selected.append(fragment) ).then(()=>{}); let itemIds = Array.from(this.selected).map(id => this.store.get(parseInt(id))).filter(Boolean); this.ui.modals.bulkEdit.modal.dataset.itemId = itemIds.join(','); if (this.ui.modals.bulkEdit.h2) { this.ui.modals.bulkEdit.h2.textContent = this.selected.size; } this.modals.bulkEdit.handleOpen(); 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; } /***************************************************************** FIELD HANDLING *****************************************************************/ async savePosts(title = '', delay = false) { if (this.changes.size > 0) { this.cancelBackup(); await this.handleBackup(); } const changes = await this.changesStore.getAll(); if (changes.length === 0) return; if (title === '') { title = `Saving ${changes.length} ${changes.length === 1 ? this.singular : this.plural}`; } let allChanges = {}; let remove = []; 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; if (change.post_status && this.shouldRemoveItemUI(change.post_status)) { remove.push(itemId); } }); if (remove.length > 0) { this.removeItems(remove); } let operation = { endpoint: this.endpoint, headers: { 'X-Action-Nonce': window.auth.getNonce('dash'), }, data: { posts: allChanges, }, delay: delay, popup: `Saving changes`, title: title }; this.queue.addToQueue(operation); } setBulkStatus(status) { if (!['publish', 'draft', 'trash', 'delete'].includes(status)) return; let ids = []; this.selected.forEach(itemID => { ids.push(itemID); this.updateItem(itemID, 'post_status', status); }); let title; switch (status) { case 'delete': title = 'Deleting'; break; default: title = window.uppercaseFirst(status)+'ing'; } if (this.shouldRemoveItemUI(status)) { this.removeItems(ids); } this.selectionHandler.clearSelection(); this.savePosts(`${title} ${ids.length} ${ids.length === 1 ? this.singular : this.plural}...`).then(()=>{}); } /*************************************************************** VIEW ***************************************************************/ render() { const items = this.store.getFiltered(); if (items.length === 0) { this.renderEmpty(); return; } switch (this.view) { case 'grid': this.renderGrid(items); break; case 'table': this.renderTable(items).then(()=>{}); break; case 'list': this.renderList(items); break; } this.updateUI(); } updateUI() { if (this.ui.bulk.action) { let options = false; let hasEdit = this.ui.bulk.action.querySelector('[value="edit"]'); let currentStatus = this.status; if (currentStatus === 'trash' && hasEdit) { window.removeChildren(this.ui.bulk.action); options = window.jvbTemplates.create('trashOptions'); } else if (currentStatus !== 'trash' && !hasEdit) { window.removeChildren(this.ui.bulk.action); options = window.jvbTemplates.create('notTrashOptions'); } if (options) { options.querySelectorAll('option').forEach((option, index)=> { if (index === 0) option.checked = true; this.ui.bulk.action.append(option); }); } this.ui.bulk.action.value = ''; } if (this.selected.size > 0) { this.selectionHandler.updateSelectionUI(); } } renderEmpty() { this.toggleTable(false); window.removeChildren(this.ui.grid); const empty = window.jvbTemplates.create('emptyState'); if (empty) { this.ui.grid.append(empty); this.a11y.announceItems(0,false,false); } } toggleTable(on = true) { if (this.ui.table.selectedColumns) this.ui.table.selectedColumns.hidden = !on; if (on && !this.ui.table.form) { let table = window.jvbTemplates.create('contentTable'); this.container.append(table); this.ui.table = window.uiFromSelectors(this.selectors.table); this.ui.table.columns = this.container.querySelectorAll(this.selectors.table.columns); } if (this.ui.table.form) { this.ui.table.form.hidden = !on; if (!on){ this.forms.clearForm(this.ui.table.form.dataset.formId) } if (this.ui.table.body) { window.removeChildren(this.ui.table.body); } } this.keyHandler = this.handleKeys.bind(this); if (on) { document.addEventListener('keydown', this.keyHandler); } else { document.removeEventListener('keydown', this.keyHandler); } } renderGrid(items) { window.removeChildren(this.ui.grid); this.toggleTable(false); this.ui.grid.classList.remove('list-view'); this.ui.grid.classList.add('grid-view'); window.chunkIt( items, (item) => this.renderGridItem(item), (fragment) => this.ui.grid.append(fragment) ).then(()=>{}); } renderList(items) { window.removeChildren(this.ui.grid); this.toggleTable(false); this.ui.grid.classList.remove('grid-view'); this.ui.grid.classList.add('list-view'); window.chunkIt( items, (item) => this.renderListItem(item), (fragment) => this.ui.grid.append(fragment) ).then(()=>{}); } async renderTable(items) { this.toggleTable(); window.removeChildren(this.ui.grid); await window.chunkIt( items, (item) => this.renderTableItem(item), (fragment) => { if (this.ui.table.body) { this.ui.table.body.append(fragment); } else { this.ui.table.table.insertBefore(fragment, this.ui.table.foot); } }, 5 ); requestAnimationFrame(() => { window.jvbSelector?.scanExistingFields(this.ui.table.table); }); } /*************************************************************** RENDER HELPERS ***************************************************************/ renderGridItem(item) { let gridItem = window.jvbTemplates.create('gridView', item); this.items.set(item.id, gridItem); return gridItem; } renderListItem(item) { let listItem = window.jvbTemplates.create('listView', item); this.items.set(item.id, listItem); return listItem; } renderTableItem(item) { let tableItem = window.jvbTemplates.create('tableView', item); this.items.set(item.id, tableItem); return tableItem; } toggleColumn(column, show) { this.ui.table.table.querySelectorAll(`.${column}`).forEach(el =>{ el.hidden = !show; }); } /*************************************************************** 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) { return (this.status === 'all' && !['publish', 'draft'].includes(newStatus)) || newStatus !== this.store.filters.status; } removeItems(items) { items.forEach(itemId => { if (this.items.has(itemId)) { let item = this.items.get(itemId); if (item) window.fade(item, false); } }); } setFilters(filters) { for (let [key, value] of Object.entries(filters)) { if (!this.allowedFilters.includes(key)) { delete filters[key]; continue; } this.cache.set(key, value); let el = this.findFilterEl(key); this.setElValue(el, value); } this.store.setFilters(filters); } setFilter(name, value) { if (!this.allowedFilters.includes(name)) return; this.cache.set(name, value); if (name === 'status') this.status = value; if (name === 'orderby') this.orderby = value; if (name === 'order') this.order = value; let el = this.findFilterEl(name, value); this.setElValue(el, value); this.store.setFilter(name, value); } deleteFilter(name, value) { if (!this.allowedFilters.includes(name)) return; if (Object.hasOwn(this.defaults, name)) { this.setFilter(name, this.defaults[name]); return; } let el = this.findFilterEl(name, value); this.setElValue(el, false); this.cache.remove(name); this.setFilter(name, ''); } setElValue(element, value) { if (!element) return; if (!value) { if (['SELECT','TEXTAREA'].includes(element.tagName)) element.value = ''; if (['text', 'search'].includes(element.type)) element.value = ''; if (element.type === 'radio') element.checked = false; return; } if (['SELECT','TEXTAREA'].includes(element.tagName)) element.value = value; if (['text', 'search'].includes(element.type)) element.value = value; if (element.type === 'radio') element.checked = true; } findFilterEl(name, value) { //Handle exceptions first (custom date elements) if (['date-filter', 'dateFrom', 'dateTo'].includes(name)) { switch (name) { case 'date-filter': name = 'month'; break; case 'dateFrom': name = 'start'; break; case 'dateTo': name = 'end'; break; } return this.ui.modals.date[name]; } // Handle taxonomy filters if (name.includes('tax_')) { const taxonomy = name.replace('tax_', ''); const element = this.ui.filters.taxonomies?.[taxonomy]; if (element) { return element; } console.warn('Taxonomy filter element not found:', taxonomy); return null; } if (!Object.hasOwn(this.ui.filters, name)) { console.warn('Filter el not found: ', name); return false; } let el = this.ui.filters[name]; if (typeof el === 'object') { if (!Object.hasOwn(this.ui.filters[name], value)) { return false; } el = this.ui.filters[name][value]; } return el; } /*************************************************************** CLEANUP ***************************************************************/ resetForm(form) { // Clear text inputs, textareas form.querySelectorAll('input[type="hidden"], input[type="text"], input[type="number"], input[type="email"], input[type="url"], textarea').forEach(input => { input.value = ''; }); // Uncheck checkboxes and radios form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(input => { input.checked = false; }); // Reset selects to first option form.querySelectorAll('select').forEach(select => { select.selectedIndex = 0; }); // Clear any selected items displays form.querySelectorAll('.selected-items').forEach(container => { window.removeChildren(container); }); // Clear upload previews form.querySelectorAll('.item-grid.preview').forEach(grid => { window.removeChildren(grid); }); } destroy() { window.debouncer.cancel(`changes-${this.content}`); if (this.changes.size > 0) { this.changesStore.saveMany(this.changes).then(()=>{}); this.changes.clear(); } if (this.timelineSortables) { this.timelineSortables.forEach(sortable => sortable.destroy()); this.timelineSortables = []; } for (let [name, modal] of Object.entries(this.ui.modals)) { if (modal.form) { modal.form.removeEventListener('submit', this.submitHandler); } } document.removeEventListener('click', this.clickHandler); document.removeEventListener('change', this.changeHandler); if (this.ui.filters.search) { this.ui.filters.search.removeEventListener('input', this.handleInput); } } } 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, }); } } }); });