| | |
| | | if (refs.trash) refs.trash.dataset.id = data.id; |
| | | }; |
| | | const imageSetup = function(el, refs, data) { |
| | | if (data?.fields?.post_thumbnail) { |
| | | const thumbnail = data.images[data.fields.post_thumbnail] ?? {}; |
| | | let hasThumbnail = data?.fields?.post_thumbnail || data?.fields?.thumbnail; |
| | | if (hasThumbnail) { |
| | | const thumbnail = data.images[hasThumbnail] ?? {}; |
| | | refs.img.src = thumbnail.medium??''; |
| | | refs.img.alt = thumbnail.alt??data.fields.post_title??''; |
| | | } |
| | |
| | | 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); |
| | | }); |
| | | data.prefix = `${data.id}-`; |
| | | |
| | | manyRefs?.status?.forEach(el => { |
| | | if (el.value === data.status) { |
| | | el.checked = true; |
| | | } |
| | | }); |
| | | window.jvbPopulate.populate(el, data); |
| | | |
| | | // |
| | | // 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 => { |
| | |
| | | month: 'dialog.date-range .month-select', |
| | | } |
| | | }, |
| | | wrap: '#item-wrap', |
| | | grid: `.${this.content}.item-grid`, |
| | | table: { |
| | | nav: '#vertical', |
| | | form: 'form.table', |
| | | table: 'form.table table', |
| | | body: 'form.table body', |
| | | body: 'form.table tbody', |
| | | head: 'form.table thead', |
| | | foot: 'form.table tfoot', |
| | | selectedColumns: '.all-filters .multi-select', |
| | | columns: 'thead th', |
| | | }, |
| | | bulk: { |
| | | hasEdit: '.bulk-action-select [value="edit"]', |
| | | action: '.bulk-action-select', |
| | | count: '.bulk-controls .selected-count', |
| | | control: '.bulk-controls .bulk-actions', |
| | |
| | | }, |
| | | date: '[data-filter="date"]' |
| | | }, |
| | | uploader: 'details.uploader' |
| | | uploader: { |
| | | details: 'details.uploader', |
| | | form: 'details.uploader form', |
| | | uploader: 'details.uploader [data-field-type="upload"]' |
| | | } |
| | | } |
| | | |
| | | this.ui = window.uiFromSelectors(this.selectors); |
| | |
| | | this.isTimeline = !!document.querySelector('[data-timeline]'); |
| | | } |
| | | initUploader() { |
| | | if (!this.ui.uploader) return; |
| | | if (!this.ui.uploader.form) return; |
| | | this.uploadForm = this.forms.registerForm(this.ui.uploader.form).id??false; |
| | | |
| | | window.jvbUploads.scanFields(this.ui.uploader); |
| | | // window.jvbUploads.scanFields(this.ui.uploader); |
| | | window.jvbUploads.subscribe((event, data) => { |
| | | if (event === 'sent-to-queue') { |
| | | if (data === this.ui.uploader.dataset.uploader) { |
| | | if (data.field.id === this.ui.uploader.uploader.dataset.uploader) { |
| | | if (this.uploadForm ) { |
| | | this.forms.store.delete(this.uploadForm); |
| | | } |
| | | |
| | | window.debouncer.schedule('crud-complete', ()=> { |
| | | this.store.clearCache(); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | if (event === 'sent-to-queue' && data.field) { |
| | | const fieldName = data.field.config.name; |
| | | const itemId = data.field.config.itemID; |
| | | if (itemId && fieldName) { |
| | | if (this.changes.has(itemId)) { |
| | | delete this.changes.get(itemId)[fieldName]; |
| | | } |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | initModals() { |
| | |
| | | { name: 'modified', keyPath: 'modified'}, |
| | | { name: 'title', keyPath: 'title'}, |
| | | ], |
| | | isAuth: true, |
| | | filters: filters, |
| | | ignore: ['content', 'user'], |
| | | TTL: 60 * 60 * 1000, //1 hour cache |
| | |
| | | span: '.bulk-select label span' |
| | | }, |
| | | wrapper: { |
| | | wrapper: '.wrap' |
| | | wrapper: this.selectors.wrap, |
| | | }, |
| | | item: { |
| | | idAttribute: 'itemId' |
| | | } |
| | | }); |
| | | }, this.ui.views.table !== null); |
| | | this.selectionHandler.subscribe((event, data) => { |
| | | this.selected = new Set([...data.selectedItems].map(id => parseInt(id))); |
| | | this.ui.bulk.control.hidden = this.selected.size === 0; |
| | |
| | | && data.status === 'completed') { |
| | | this.store.clearCache(); |
| | | } |
| | | console.log('CRUD.js queue subscription'); |
| | | console.log(event, data); |
| | | |
| | | |
| | | if (event === 'operation-status' |
| | | && data.status === 'completed' |
| | | && data.endpoint === 'uploads/groups') { |
| | | console.log('Grouped Uploads completed'); |
| | | if (data.result && data.result.group_mappings) { |
| | | console.log('Handling group mapping from queue response'); |
| | | this.handleGroupMappings(data.result.group_mappings); |
| | | } |
| | | console.log('Cleared local cache. Refresh to see changes'); |
| | | |
| | | this.store.clearCache(); |
| | | } |
| | | |
| | | if (event === 'operation-status' |
| | | && data.status === 'completed' |
| | | && data.type === 'content_update') { |
| | | console.log('Cleared local cache. Refresh to see changes'); |
| | | |
| | | 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); |
| | | if (!data.result || !data.result.success || !data.result.errors) |
| | | { |
| | | console.warn('Content update completed but no results', data); |
| | | return; |
| | | } |
| | | |
| | | // Get successfully processed post IDs |
| | | const successfulIds = Object.keys(data.result.posts); |
| | | |
| | | if (successfulIds.length === 0) { |
| | | 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; |
| | | } |
| | | |
| | | // Clear from both persistent and in-memory storage |
| | | this.changesStore.deleteMany(successfulIds); |
| | | successfulIds.forEach(id => this.changes.delete(id)); |
| | | 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' && data.type === 'content_update') { |
| | | if (data instanceof FormData) return; |
| | | |
| | | for ( let [id, changes] of Object.entries(data.posts)) { |
| | | this.compareStored(id, changes); |
| | | } |
| | | } |
| | | |
| | | }); |
| | | } |
| | | checkCompletedChanges(items) { |
| | | for (let [id, data] of items) { |
| | | this.compareStored(id, data); |
| | | } |
| | | } |
| | | compareStored(id, data) { |
| | | let stored = this.changesStore.get(id); |
| | | if (!stored) return; |
| | | |
| | | for (let [field, value] of Object.entries(data)) { |
| | | if (Object.hasOwn(stored, field)) { |
| | | let changes = window.getDifferences.map(stored[field], value); |
| | | if (!changes) { |
| | | delete stored[field]; |
| | | } else { |
| | | stored[field] = changes; |
| | | } |
| | | } |
| | | } |
| | | |
| | | let hasID = Object.hasOwn(stored, 'id'); |
| | | let hasContent = Object.hasOwn(stored, 'content'); |
| | | if ((hasID && hasContent && Object.keys(stored).length === 2) |
| | | || ((hasID || hasContent) && Object.keys(stored).length === 1) |
| | | || Object.keys(stored).length === 0 |
| | | ) { |
| | | this.changesStore.delete(id); |
| | | this.store.clearCache(); |
| | | } else { |
| | | this.changesStore.save(stored); |
| | | } |
| | | } |
| | | checkFailedChanges(items) { |
| | | //TODO do something. |
| | | } |
| | | |
| | | initSettings() { |
| | | this.defaults = { |
| | |
| | | //current view (defaults to grid) |
| | | let defaultView = this.container.dataset.view??'grid' |
| | | this.view = this.cache.get('view')??defaultView; |
| | | |
| | | if (!this.view) { |
| | | this.view = defaultView; |
| | | } |
| | | if (this.view !== defaultView) { |
| | | this.ui.views[this.view].checked = true; |
| | | } |
| | |
| | | if (this.ui.table.nav && tabDirection === 'vertical') { |
| | | this.ui.table.nav.checked = true; |
| | | } |
| | | this.tabNav = tabDirection !== 'horizontal'; |
| | | |
| | | |
| | | |
| | |
| | | default: 'closed', |
| | | }, |
| | | showUploader: { |
| | | element: this.ui.uploader, |
| | | element: this.ui.uploader.details, |
| | | default: 'open' |
| | | } |
| | | }; |
| | |
| | | const form = e.target; |
| | | const modal = form.closest('dialog'); |
| | | if (!modal) return; |
| | | let title = `Saving changes for multiple ${this.plural}`; |
| | | if (modal.classList.contains('edit')) { |
| | | title = 'Saving your edits...'; |
| | | } else if (modal.classList.contains('create')) { |
| | | title = `Creating your new ${this.singular}`; |
| | | |
| | | if (modal.classList.contains('create')) { |
| | | this.handleCreateSubmit(modal); |
| | | return; |
| | | } |
| | | |
| | | 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 isBulkAction = e.target.matches('.bulk-action-select'); |
| | | const isView = e.target.matches('[data-view]'); |
| | | |
| | | if (this.view === 'table') { |
| | | this.handleTableChange(e); |
| | | } |
| | | if (!inItem && !isFilter && !isBulkAction && !isView) return; |
| | | |
| | | if (!this.isPopulating && inItem && !e.target.closest('[data-ignore], .select-item')) { |
| | |
| | | return; |
| | | } |
| | | |
| | | // Table-specific handlers |
| | | if (this.view === 'table') { |
| | | if (e.target.matches('details.multi-select')) { |
| | | this.toggleColumn(e.target.id, e.target.checked); |
| | | return; |
| | | } |
| | | } |
| | | handleTableChange(e) { |
| | | // 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'); |
| | | 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]; |
| | |
| | | |
| | | handleItemUpdate(e) { |
| | | let item = window.targetCheck(e, '[data-item-id]'); |
| | | |
| | | if (!item) return; |
| | | |
| | | // Check if inside a collection field first |
| | | const collection = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]'); |
| | | |
| | | let name, value; |
| | | if (collection) { |
| | | name = collection.dataset.field; |
| | | value = this.forms.getFieldValue(collection); |
| | | } else { |
| | | let field = e.target.closest('[data-field]'); |
| | | name = field.dataset.field; |
| | | value = this.forms.getFieldValue(e.target); |
| | | } |
| | | |
| | | item.dataset.itemId.split(',').forEach(itemId => { |
| | | let field = this.forms.getField(e.target); |
| | | 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.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(); |
| | | //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')) { |
| | | if (typeof itemId === 'number' || !String(itemId).includes('group')) { |
| | | this.scheduleSave(); |
| | | } |
| | | } |
| | |
| | | return; |
| | | } |
| | | |
| | | if (e.target.matches(this.selectors.buttons.create)) { |
| | | if (e.target.matches(this.selectors.buttons.create) || e.target.closest(this.selectors.buttons.create)) { |
| | | this.openCreateModal(); |
| | | } |
| | | } |
| | |
| | | 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) { |
| | |
| | | // 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')) |
| | | const rows = Array.from(this.ui.table.body.children) |
| | | .filter(row => this.isEditableRow(row)); |
| | | |
| | | return goBackward ? rows[rows.length - 1] : rows[0]; |
| | |
| | | this.activeItem = item.id; |
| | | this.ui.modals.edit.modal.dataset.itemId = itemID; |
| | | this.ui.modals.edit.modal.dataset.content = this.content; |
| | | this.ui.modals.edit.h2.textContent = `Editing ${item.fields.post_title === '' ? this.singular : item.fields.post_title}`; |
| | | let title; |
| | | if (Object.hasOwn(item.fields, 'post_title')) { |
| | | title = item.fields.post_title; |
| | | } else if (Object.hasOwn(item.fields, 'name')) { |
| | | title = item.fields.name; |
| | | } |
| | | this.ui.modals.edit.h2.textContent = `Editing ${title === '' ? this.singular : title}`; |
| | | this.ui.modals.edit.form.dataset.formId = `edit-${itemID}`; |
| | | |
| | | this.forms.registerForm(this.ui.modals.edit.form, {cache: false}); |
| | | |
| | | this.isPopulating = true; |
| | | this.populate.populate(this.ui.modals.edit.form, item); |
| | | this.isPopulating = false; |
| | | |
| | | this.modals.edit.handleOpen(); |
| | | this.forms.registerForm(this.ui.modals.edit.form, {cache: false, |
| | | autoUpload: true,}); |
| | | |
| | | |
| | | this.populate.populate(this.ui.modals.edit.form, item); |
| | | //For quill/taxonomy selector's async setups |
| | | requestAnimationFrame(() => { |
| | | requestAnimationFrame(() => { |
| | | setTimeout(() => { |
| | | this.isPopulating = false; |
| | | }, 50); |
| | | }); |
| | | }); |
| | | |
| | | } |
| | | openBulkEditModal() { |
| | | window.removeChildren(this.ui.modals.bulkEdit.selected); |
| | |
| | | } |
| | | this.modals.bulkEdit.handleOpen(); |
| | | |
| | | this.forms.registerForm(this.ui.modals.bulkEdit.form, {cache:false}); |
| | | |
| | | this.forms.registerForm(this.ui.modals.bulkEdit.form, {cache:false}); |
| | | this.isPopulating = true; |
| | | this.populate.populate(this.ui.modals.edit.form, item); |
| | | this.isPopulating = false; |
| | | requestAnimationFrame(() => { |
| | | requestAnimationFrame(() => { |
| | | this.isPopulating = false; |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /***************************************************************** |
| | |
| | | this.cancelBackup(); |
| | | await this.handleBackup(); |
| | | } |
| | | const changes = await this.changesStore.getAll(); |
| | | console.log('Saving Changes: ', changes); |
| | | 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 === '') { |
| | |
| | | |
| | | 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; |
| | | |
| | |
| | | let operation = { |
| | | endpoint: this.endpoint, |
| | | headers: { |
| | | 'X-WP-Nonce': window.auth.getNonce(), |
| | | 'X-Action-Nonce': window.auth.getNonce('dash'), |
| | | }, |
| | | data: { |
| | |
| | | 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; |
| | |
| | | |
| | | if (on && !this.ui.table.form) { |
| | | let table = window.jvbTemplates.create('contentTable'); |
| | | this.container.append(table); |
| | | this.ui.wrap.append(table); |
| | | this.ui.table = window.uiFromSelectors(this.selectors.table); |
| | | this.ui.table.columns = this.container.querySelectorAll(this.selectors.table.columns); |
| | | } |
| | |
| | | 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); |
| | | } |
| | | this.ui.table.body.append(fragment); |
| | | }, |
| | | 5 |
| | | ); |
| | |
| | | let uploader = window.jvbUploads; |
| | | let field = uploader.fields.get(fieldId); |
| | | |
| | | |
| | | console.log(posts); |
| | | console.log(field); |
| | | |
| | | let added = []; |
| | | posts.forEach(post => { |
| | | const placeholderPost = { |
| | |
| | | } |
| | | |
| | | handleGroupMappings(mappings) { |
| | | console.log('[CRUD] Applying group mappings:', mappings); |
| | | // mappings = { "group_abc123": 456, "group_def456": 789 } |
| | | |
| | | for (const [groupId, postId] of Object.entries(mappings)) { |
| | |
| | | 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; |
| | | }); |
| | | form.reset(); |
| | | |
| | | // Clear any selected items displays |
| | | form.querySelectorAll('.selected-items').forEach(container => { |