| | |
| | | /** |
| | | * Main CRUD Manager - Coordinates everything |
| | | */ |
| | | class CRUDManager { |
| | | constructor(config) { |
| | | 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.config = config; |
| | | this.content = config.content || false; |
| | | this.settings = window.jvbUserSettings; |
| | | this.a11y = window.jvbA11y; |
| | | if (!this.content) { |
| | | return; |
| | | } |
| | | this.error = window.jvbError; |
| | | this.populate = window.jvbPopulate; |
| | | this.cache = new window.jvbCache(this.content); |
| | | |
| | | this.activeItem = null; |
| | | this.isTimeline = false; |
| | | this.currentItemID = null; |
| | | this.isPopulating = false; |
| | | |
| | | 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; |
| | | //Track Changes |
| | | this.changes = new Map(); |
| | | this.items = new Map(); |
| | | |
| | | 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; |
| | | 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) { |
| | | 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??''; |
| | | } |
| | | |
| | | } |
| | | |
| | | let itemsToRemove = []; |
| | | console.log(data); |
| | | 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); |
| | | 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; |
| | | } |
| | | }); |
| | | |
| | | 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; |
| | | } |
| | | 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); |
| | | }); |
| | | |
| | | // 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); |
| | | 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; |
| | | } |
| | | }); |
| | | } |
| | | }, 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 (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', '')); |
| | | } |
| | | } |
| | | }); |
| | | |
| | | if (Object.keys(theChanges).length === 0) { |
| | | return; |
| | | } |
| | | T.define('emptyState'); |
| | | |
| | | this.savePosts(theChanges, title); |
| | | } |
| | | 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??''; |
| | | } |
| | | |
| | | 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; |
| | | if (refs.label) { |
| | | refs.label.title = item.fields.post_title; |
| | | } |
| | | } |
| | | } |
| | | let operation = { |
| | | endpoint: 'content', |
| | | headers: { |
| | | 'action_nonce': window.auth.getNonce('dash'), |
| | | }, |
| | | data: { |
| | | posts: changes, |
| | | }, |
| | | delay: true, |
| | | 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'}`); |
| | | }); |
| | | T.define('trashOptions'); |
| | | T.define('notTrashOptions'); |
| | | T.define('contentTable'); |
| | | } |
| | | |
| | | initElements() { |
| | | this.elements = { |
| | | 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: 'dialog.create', |
| | | edit: 'dialog.edit', |
| | | bulkEdit: 'dialog.bulkEdit' |
| | | 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', |
| | | } |
| | | }, |
| | | container: '.crud[data-content]', |
| | | grid: '.item-grid', |
| | | bulkSelectActions: '.bulk-action-select', |
| | | forms: { |
| | | create: 'dialog.create form', |
| | | edit: 'dialog.edit form', |
| | | bulkEdit: 'dialog.bulkEdit form' |
| | | 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', |
| | | }, |
| | | uploader: 'details.uploader' |
| | | }; |
| | | this.ui = window.uiFromSelectors(this.elements); |
| | | if (this.ui.uploader) { |
| | | window.jvbUploads.scanFields(document.querySelector(this.elements.uploader)); |
| | | 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: 'details.uploader', |
| | | form: 'details.uploader form', |
| | | uploader: 'details.uploader [data-field-type="upload"]' |
| | | } |
| | | } |
| | | |
| | | 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.form) return; |
| | | this.uploadForm = this.forms.registerForm(this.ui.uploader.form).id??false; |
| | | |
| | | // window.jvbUploads.scanFields(this.ui.uploader); |
| | | window.jvbUploads.subscribe((event, data) => { |
| | | if (event === 'sent-to-queue') { |
| | | console.log(data); |
| | | if (data === this.ui.uploader.querySelector('[data-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(); |
| | | }); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | 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; |
| | | 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() { |
| | | this.modals = {}; |
| | | for (let [name, modal] of Object.entries(this.ui.modals)) { |
| | | if (!modal.modal) continue; |
| | | this.modals[name] = new window.jvbModal(modal.modal); |
| | | |
| | | // Set up global event delegation |
| | | this.setupEventDelegation(); |
| | | 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.setupFilters(); |
| | | this.resetForm(this.ui.modals[name].form); |
| | | |
| | | this.initialized = true; |
| | | } |
| | | 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': |
| | | |
| | | 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; |
| | | break; |
| | | } |
| | | }) |
| | | } |
| | | |
| | | 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; |
| | | initStore(cached) { |
| | | let filters = { |
| | | ... this.defaults, |
| | | ...cached |
| | | }; |
| | | |
| | | 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; |
| | | 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'}, |
| | | ], |
| | | isAuth: true, |
| | | filters: filters, |
| | | ignore: ['content', 'user'], |
| | | TTL: 60 * 60 * 1000, //1 hour cache |
| | | showLoading: true, |
| | | }, |
| | | { |
| | | storeName: 'changes', |
| | | keyPath: 'id' |
| | | } |
| | | } |
| | | ] |
| | | ); |
| | | |
| | | let createButton = e.target.closest('.create-item'); |
| | | if (createButton) { |
| | | this.formController.registerForm(this.ui.forms.create); |
| | | this.modals.create.handleOpen(); |
| | | } |
| | | this.changesStore = stores['changes']; |
| | | this.store = stores[this.content]; |
| | | |
| | | let clearSelection = e.target.closest('.cancel-bulk'); |
| | | if (clearSelection) { |
| | | this.viewController.selectAll(false); |
| | | this.store.subscribe((event, data) => { |
| | | switch (event) { |
| | | case 'data-loaded': |
| | | this.render(); |
| | | this.selectionHandler.collectItems(); |
| | | break; |
| | | } |
| | | }); |
| | | |
| | | // 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(); |
| | | 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; |
| | | } |
| | | }); |
| | | } |
| | | handleChange(e) { |
| | | if (e.target.closest('[data-id]')) { |
| | | if (this.isTimeline) { |
| | | this.handleTimelineTableChange(e); |
| | | 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) { |
| | | console.log('Handling group mapping from queue response'); |
| | | this.handleGroupMappings(data.result.group_mappings); |
| | | } |
| | | |
| | | this.store.clearCache(); |
| | | } |
| | | |
| | | if (event === 'operation-status' |
| | | && data.status === 'completed' |
| | | && data.type === 'content_update') { |
| | | |
| | | this.store.clearCache(); |
| | | |
| | | if (!data.result || !data.result.success || !data.result.errors) |
| | | { |
| | | console.warn('Content update completed but no results', data); |
| | | return; |
| | | } |
| | | |
| | | 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.handleTableChange(e); |
| | | this.changesStore.save(stored); |
| | | } |
| | | } |
| | | checkFailedChanges(items) { |
| | | //TODO do something. |
| | | } |
| | | |
| | | 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.details, |
| | | 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; |
| | | } |
| | | 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 = ''; |
| | | |
| | | 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 |
| | | 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; |
| | | } |
| | | |
| | | switch (e.target.value) { |
| | | 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.populateBulkEdit(); |
| | | this.modals.bulkEdit.handleOpen(); |
| | | this.openBulkEditModal(); |
| | | break; |
| | | case 'publish': |
| | | this.setBulkStatus('publish'); |
| | | case 'trash': |
| | | case 'delete': |
| | | this.setBulkStatus(bulkAction.value); |
| | | break; |
| | | case 'draft': |
| | | this.setBulkStatus('draft'); |
| | | break; |
| | | case 'trash': |
| | | this.setBulkStatus('trash'); |
| | | break; |
| | | 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; |
| | | |
| | | // 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 => { |
| | | this.updateItem(itemId, name, value); |
| | | }); |
| | | } |
| | | 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( |
| | | `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) || e.target.closest(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': |
| | | this.setBulkStatus('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; |
| | | } |
| | | } |
| | | 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 = {}; |
| | | 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(); |
| | | } |
| | | 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); |
| | | handleInput(e) { |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | let query = e.target.value.trim(); |
| | | let key = `${this.content}-search`; |
| | | |
| | | 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'); |
| | | if (query.length === 0) { |
| | | this.deleteFilter('search', ''); |
| | | return; |
| | | } |
| | | |
| | | // Open the selector in filter mode |
| | | window.jvbSelector.openForFilter( |
| | | taxonomy, |
| | | (selectedIds, taxonomy) => this.handleBulkTaxonomy(selectedIds, taxonomy) |
| | | // 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 |
| | | ); |
| | | } |
| | | handleBulkTaxonomy(selectedIds, taxonomy) { |
| | | // Callback when terms are selected |
| | | if (selectedIds.length > 0) { |
| | | selectedIds = selectedIds.join(','); |
| | | let changes = {}; |
| | | let selected = Array.from(this.viewController.selectedItems); |
| | | |
| | | handleKeys(e) { |
| | | if (!this.tabNav) return; |
| | | |
| | | selected.forEach(sel => { |
| | | changes[sel] = { |
| | | content: this.content |
| | | }; |
| | | changes[sel][taxonomy] = selectedIds; |
| | | }); |
| | | if (e.key === 'Tab') { |
| | | e.preventDefault(); |
| | | |
| | | const currentCell = e.target.closest('[data-field]'); |
| | | const currentRow = e.target.closest('tr'); |
| | | |
| | | let title = `Adding ${selected.length} ${this.config.plural??'posts'} to ${selectedIds.length} ${jvbSettings.labels[taxonomy].plural}`; |
| | | this.viewController.clearSelection(); |
| | | this.savePosts(changes, title); |
| | | 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`); |
| | | } |
| | | } |
| | | |
| | | setBulkStatus(status) { |
| | | if (!['publish', 'draft', 'trash', 'delete'].includes(status)){ |
| | | return; |
| | | 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; |
| | | } |
| | | |
| | | let changes = {}; |
| | | for (let selected of this.viewController.selectedItems) { |
| | | changes[selected] = { |
| | | post_status: status, |
| | | content: this.content |
| | | }; |
| | | 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; |
| | | 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.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); |
| | | //For quill/taxonomy selector's async setups |
| | | requestAnimationFrame(() => { |
| | | requestAnimationFrame(() => { |
| | | this.isPopulating = false; |
| | | }); |
| | | }); |
| | | |
| | | } |
| | | 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); |
| | | requestAnimationFrame(() => { |
| | | requestAnimationFrame(() => { |
| | | this.isPopulating = false; |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | FIELD HANDLING |
| | | *****************************************************************/ |
| | | |
| | | async savePosts(title = '', delay = false) { |
| | | if (this.changes.size > 0) { |
| | | this.cancelBackup(); |
| | | await this.handleBackup(); |
| | | } |
| | | 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 === '') { |
| | | title = `Saving ${changes.length} ${changes.length === 1 ? this.singular : this.plural}`; |
| | | } |
| | | |
| | | let allChanges = {}; |
| | | let remove = []; |
| | | |
| | | changes.forEach(change => { |
| | | let itemId = change.id; |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | | * 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; |
| | | let ids = []; |
| | | this.selected.forEach(itemID => { |
| | | ids.push(itemID); |
| | | this.updateItem(itemID, 'post_status', status); |
| | | }); |
| | | let title; |
| | | switch (status) { |
| | | case 'delete': |
| | |
| | | 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 |
| | | } |
| | | if (this.shouldRemoveItemUI(status)) { |
| | | this.removeItems(ids); |
| | | } |
| | | // Clear selection even if items aren't being removed |
| | | this.viewController.clearSelection(); |
| | | this.selectionHandler.clearSelection(); |
| | | |
| | | this.savePosts(`${title} ${ids.length} ${ids.length === 1 ? this.singular : this.plural}...`).then(()=>{}); |
| | | |
| | | 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); |
| | | } |
| | | /*************************************************************** |
| | | 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(); |
| | | } |
| | | 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); |
| | | 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 { |
| | | if (this.ui.bulkSelectActions && !this.ui.bulkSelectActions.querySelector('[value="edit"]')) { |
| | | window.removeChildren(this.ui.bulkSelectActions); |
| | | document.removeEventListener('keydown', this.keyHandler); |
| | | } |
| | | |
| | | 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; |
| | | renderGrid(items) { |
| | | window.removeChildren(this.ui.grid); |
| | | this.toggleTable(false); |
| | | |
| | | window.removeChildren(container); |
| | | for (let selected of this.viewController.selectedItems) { |
| | | let item = this.store.get(selected); |
| | | this.ui.grid.classList.remove('list-view'); |
| | | this.ui.grid.classList.add('grid-view'); |
| | | |
| | | 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); |
| | | window.chunkIt( |
| | | items, |
| | | (item) => this.renderGridItem(item), |
| | | (fragment) => this.ui.grid.append(fragment) |
| | | ).then(()=>{}); |
| | | } |
| | | |
| | | populateEditForm(itemID) { |
| | | this.currentItemID = itemID; |
| | | renderList(items) { |
| | | window.removeChildren(this.ui.grid); |
| | | this.toggleTable(false); |
| | | |
| | | 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); |
| | | } |
| | | 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(()=>{}); |
| | | } |
| | | |
| | | setupFilters() { |
| | | // Search |
| | | const searchInput = document.querySelector('.all-filters 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'); |
| | | 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); |
| | | }); |
| | | } |
| | | |
| | | destroy() { |
| | | document.querySelectorAll('[data-filter]').forEach(filter => { |
| | | filter.removeEventListener('change', this.filterHandler); |
| | | /*************************************************************** |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Initialize when ready |
| | | document.addEventListener('DOMContentLoaded', async function() { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |